From 4ae4c2ee954fe8285a324db8b444dd190e49c972 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 28 Apr 2026 09:26:51 +0200 Subject: [PATCH 1/7] chore: add gitea MCP helper script --- get_gitea_tools.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 get_gitea_tools.py diff --git a/get_gitea_tools.py b/get_gitea_tools.py new file mode 100644 index 00000000..e0a9f121 --- /dev/null +++ b/get_gitea_tools.py @@ -0,0 +1,50 @@ +import json +import subprocess +import sys +import time + +def send(proc, payload): + proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8")) + proc.stdin.flush() + +def read_line(proc, timeout=10.0): + start = time.time() + while time.time() - start < timeout: + line = proc.stdout.readline() + if line: + return line.decode("utf-8", errors="replace").strip() + time.sleep(0.05) + return "" + +def main(): + proc = subprocess.Popen( + ["python3", "scripts/run-gitea-mcp.py"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + send(proc, { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0.0"}, + }, + }) + init_resp = read_line(proc) + send(proc, { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }) + tools_resp = read_line(proc) + print(tools_resp) + finally: + proc.terminate() + +if __name__ == "__main__": + main() -- 2.45.2 From 2fa8fc0f870c226d57a9a3c456c9bb0deef015ae Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 22:00:51 +0000 Subject: [PATCH 2/7] refactor: remove findings lifecycle backfill runtime surfaces (#294) ## Summary - decommission the legacy findings lifecycle backfill substrate across command, job, service, and UI layers - remove related platform capabilities, operation catalog entries, and action surface exemptions - add regression and removal verification tests to ensure runtime integrity and surface absence - include spec, plan, tasks, and data-model artifacts for the removal slice ## Scope - active spec: specs/253-remove-findings-backfill-runtime-surfaces - target branch: dev ## Validation - integrated regression and removal verification tests for console, findings, and system ops surfaces - audit log and capability trace verification for the removal path Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/294 --- .github/agents/copilot-instructions.md | 8 +- .../TenantpilotBackfillFindingLifecycle.php | 129 --- .../Commands/TenantpilotRunDeployRunbooks.php | 56 -- .../app/Filament/Pages/Auth/Login.php | 5 + .../Pages/Governance/GovernanceInbox.php | 494 ++++++++++ .../Pages/Reviews/CustomerReviewWorkspace.php | 80 +- .../Filament/Pages/Reviews/ReviewRegister.php | 20 +- .../Pages/Settings/WorkspaceSettings.php | 75 +- .../app/Filament/Pages/TenantDashboard.php | 35 +- .../ManagedTenantOnboardingWizard.php | 34 +- .../Filament/Resources/FindingResource.php | 22 +- .../FindingResource/Pages/ListFindings.php | 87 +- .../FindingResource/Pages/ViewFinding.php | 4 +- .../Filament/Resources/ReviewPackResource.php | 16 +- .../Resources/TenantReviewResource.php | 154 +-- .../app/Filament/System/Pages/Dashboard.php | 17 +- .../System/Pages/Directory/ViewWorkspace.php | 77 ++ .../Filament/System/Pages/Ops/Runbooks.php | 291 ------ .../Widgets/Tenant/TenantReviewPackCard.php | 14 + .../Controllers/LocalizationController.php | 80 ++ .../Http/Middleware/ApplyResolvedLocale.php | 29 + .../app/Jobs/BackfillFindingLifecycleJob.php | 398 -------- ...dingLifecycleTenantIntoWorkspaceRunJob.php | 378 -------- .../BackfillFindingLifecycleWorkspaceJob.php | 95 -- apps/platform/app/Models/User.php | 1 + .../Providers/Filament/AdminPanelProvider.php | 23 +- .../Filament/SystemPanelProvider.php | 9 + .../Filament/TenantPanelProvider.php | 13 +- .../WorkspaceCommercialLifecycleResolver.php | 410 ++++++++ .../Services/Localization/LocaleResolver.php | 215 +++++ .../app/Services/ReviewPackService.php | 20 +- ...indingsLifecycleBackfillRunbookService.php | 739 --------------- .../FindingsLifecycleBackfillScope.php | 81 -- .../app/Services/Settings/SettingsWriter.php | 135 ++- .../OperationRunTriageService.php | 2 - .../app/Support/Auth/PlatformCapabilities.php | 4 +- .../app/Support/Badges/BadgeCatalog.php | 1 + .../app/Support/Badges/BadgeDomain.php | 1 + .../Domains/CommercialLifecycleStateBadge.php | 26 + .../GovernanceInboxSectionBuilder.php | 888 ++++++++++++++++++ .../TrustedState/TrustedStatePolicy.php | 88 -- .../platform/app/Support/OperationCatalog.php | 2 - .../app/Support/Settings/SettingsRegistry.php | 61 +- .../ActionSurface/ActionSurfaceExemptions.php | 20 +- .../Support/Workspaces/WorkspaceResolver.php | 35 + apps/platform/bootstrap/app.php | 6 + ...00_add_preferred_locale_to_users_table.php | 25 + .../database/seeders/PlatformUserSeeder.php | 1 - apps/platform/lang/de/baseline-compare.php | 88 ++ apps/platform/lang/de/findings.php | 31 + apps/platform/lang/de/localization.php | 230 +++++ apps/platform/lang/en/localization.php | 230 +++++ .../governance-artifact-truth.blade.php | 16 +- .../entries/tenant-review-section.blade.php | 6 +- .../entries/tenant-review-summary.blade.php | 24 +- .../views/filament/pages/auth/login.blade.php | 6 +- .../governance/governance-inbox.blade.php | 164 ++++ .../customer-review-workspace.blade.php | 8 +- .../filament/partials/context-bar.blade.php | 41 +- .../partials/locale-switcher.blade.php | 110 +++ .../pages/directory/view-workspace.blade.php | 66 ++ .../system/pages/ops/runbooks.blade.php | 105 +-- .../tenant/tenant-review-pack-card.blade.php | 7 + apps/platform/routes/web.php | 16 + ...eFindingsLifecycleBackfillCommandsTest.php | 20 + .../Spec113/DeployRunbooksCommandTest.php | 31 - .../Evidence/EvidenceSnapshotResourceTest.php | 51 + .../CoreGovernanceSurfaceLocalizationTest.php | 16 + .../AdminFindingsNoMaintenanceActionsTest.php | 21 - .../Feature/Findings/FindingBackfillTest.php | 136 --- .../FindingWorkflowRegressionTest.php | 61 ++ ...ationalControlFindingsBackfillGateTest.php | 101 -- ...oveFindingsLifecycleBackfillActionTest.php | 33 + .../GovernanceInboxAuthorizationTest.php | 99 ++ .../GovernanceInboxNavigationContextTest.php | 64 ++ .../Governance/GovernanceInboxPageTest.php | 143 +++ .../Guards/LivewireTrustedStateGuardTest.php | 1 - .../AuthAndSystemSurfaceLocalizationTest.php | 43 + .../Localization/LocalePreferenceFlowTest.php | 87 ++ .../LocalizedNotificationFormattingTest.php | 47 + .../MachineFormatInvarianceTest.php | 31 + .../TranslationFallbackGuardTest.php | 23 + .../WorkspaceDefaultLocaleTest.php | 50 + ...ManagedTenantOnboardingEntitlementTest.php | 90 +- .../NoAdHocOperationalControlBypassTest.php | 34 +- .../CommandModelSmokeTest.php | 7 + .../ReviewPack/ReviewPackDownloadTest.php | 36 + .../ReviewPackEntitlementEnforcementTest.php | 106 ++- .../ReviewPack/ReviewPackGenerationTest.php | 38 + .../CustomerReviewWorkspacePackAccessTest.php | 61 +- ...dingsLifecycleBackfillControlTraceTest.php | 42 + ...ingsLifecycleBackfillAuditFailSafeTest.php | 87 -- ...indingsLifecycleBackfillBreakGlassTest.php | 111 --- ...ndingsLifecycleBackfillIdempotencyTest.php | 78 -- ...FindingsLifecycleBackfillPreflightTest.php | 202 ---- .../FindingsLifecycleBackfillStartTest.php | 253 ----- .../OperationalControlRunbookGateTest.php | 89 -- .../OpsUxStartSurfaceContractTest.php | 89 -- ...ngsLifecycleBackfillRunbookSurfaceTest.php | 59 ++ .../Spec113/AuthorizationSemanticsTest.php | 37 + .../System/ViewWorkspaceEntitlementsTest.php | 111 ++- apps/platform/tests/Pest.php | 19 + .../CommercialLifecycleStateBadgeTest.php | 20 + ...rkspaceCommercialLifecycleResolverTest.php | 199 ++++ .../Unit/Localization/LocaleResolverTest.php | 83 ++ ...gsLifecycleBackfillCapabilityTraceTest.php | 14 + .../GovernanceInboxSectionBuilderTest.php | 197 ++++ ...dingsLifecycleBackfillCatalogTraceTest.php | 12 + .../Workspaces/WorkspaceResolverTest.php | 52 + docker-compose.yml | 2 +- docs/HANDOVER.md | 1 - docs/product/implementation-ledger.md | 12 +- docs/product/spec-candidates.md | 90 +- .../checklists/requirements.md | 70 ++ .../contracts/governance-inbox.openapi.yaml | 159 ++++ .../data-model.md | 103 ++ specs/250-decision-governance-inbox/plan.md | 305 ++++++ .../quickstart.md | 65 ++ .../250-decision-governance-inbox/research.md | 104 ++ specs/250-decision-governance-inbox/spec.md | 294 ++++++ specs/250-decision-governance-inbox/tasks.md | 173 ++++ .../checklists/requirements.md | 42 + ...ial-lifecycle-overlay.logical.openapi.yaml | 465 +++++++++ .../data-model.md | 170 ++++ .../plan.md | 297 ++++++ .../quickstart.md | 109 +++ .../research.md | 84 ++ .../spec.md | 332 +++++++ .../tasks.md | 190 ++++ .../checklists/requirements.md | 60 ++ ...ranslation-governance.logical.openapi.yaml | 177 ++++ .../data-model.md | 65 ++ specs/252-platform-localization-v1/plan.md | 287 ++++++ .../quickstart.md | 39 + .../252-platform-localization-v1/research.md | 51 + specs/252-platform-localization-v1/spec.md | 319 +++++++ specs/252-platform-localization-v1/tasks.md | 187 ++++ .../checklists/requirements.md | 48 + ...fill-runtime-surface-removal.contract.yaml | 108 +++ .../data-model.md | 121 +++ .../plan.md | 237 +++++ .../quickstart.md | 36 + .../research.md | 153 +++ .../spec.md | 291 ++++++ .../tasks.md | 231 +++++ 145 files changed, 11295 insertions(+), 3918 deletions(-) delete mode 100644 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php delete mode 100644 apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php create mode 100644 apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php create mode 100644 apps/platform/app/Http/Controllers/LocalizationController.php create mode 100644 apps/platform/app/Http/Middleware/ApplyResolvedLocale.php delete mode 100644 apps/platform/app/Jobs/BackfillFindingLifecycleJob.php delete mode 100644 apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php delete mode 100644 apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php create mode 100644 apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php create mode 100644 apps/platform/app/Services/Localization/LocaleResolver.php delete mode 100644 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php delete mode 100644 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php create mode 100644 apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php create mode 100644 apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php create mode 100644 apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php create mode 100644 apps/platform/lang/de/baseline-compare.php create mode 100644 apps/platform/lang/de/findings.php create mode 100644 apps/platform/lang/de/localization.php create mode 100644 apps/platform/lang/en/localization.php create mode 100644 apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php create mode 100644 apps/platform/resources/views/filament/partials/locale-switcher.blade.php create mode 100644 apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php delete mode 100644 apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php create mode 100644 apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php delete mode 100644 apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php delete mode 100644 apps/platform/tests/Feature/Findings/FindingBackfillTest.php create mode 100644 apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php delete mode 100644 apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php create mode 100644 apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php create mode 100644 apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php create mode 100644 apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php create mode 100644 apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php create mode 100644 apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php create mode 100644 apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php create mode 100644 apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php create mode 100644 apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php create mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php create mode 100644 apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php create mode 100644 apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php create mode 100644 apps/platform/tests/Unit/Localization/LocaleResolverTest.php create mode 100644 apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php create mode 100644 apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php create mode 100644 apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php create mode 100644 apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php create mode 100644 specs/250-decision-governance-inbox/checklists/requirements.md create mode 100644 specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml create mode 100644 specs/250-decision-governance-inbox/data-model.md create mode 100644 specs/250-decision-governance-inbox/plan.md create mode 100644 specs/250-decision-governance-inbox/quickstart.md create mode 100644 specs/250-decision-governance-inbox/research.md create mode 100644 specs/250-decision-governance-inbox/spec.md create mode 100644 specs/250-decision-governance-inbox/tasks.md create mode 100644 specs/251-commercial-entitlements-billing-state/checklists/requirements.md create mode 100644 specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml create mode 100644 specs/251-commercial-entitlements-billing-state/data-model.md create mode 100644 specs/251-commercial-entitlements-billing-state/plan.md create mode 100644 specs/251-commercial-entitlements-billing-state/quickstart.md create mode 100644 specs/251-commercial-entitlements-billing-state/research.md create mode 100644 specs/251-commercial-entitlements-billing-state/spec.md create mode 100644 specs/251-commercial-entitlements-billing-state/tasks.md create mode 100644 specs/252-platform-localization-v1/checklists/requirements.md create mode 100644 specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml create mode 100644 specs/252-platform-localization-v1/data-model.md create mode 100644 specs/252-platform-localization-v1/plan.md create mode 100644 specs/252-platform-localization-v1/quickstart.md create mode 100644 specs/252-platform-localization-v1/research.md create mode 100644 specs/252-platform-localization-v1/spec.md create mode 100644 specs/252-platform-localization-v1/tasks.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/data-model.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/plan.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/research.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/spec.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 7b699aba..910f11be 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -262,6 +262,10 @@ ## Active Technologies - PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) - PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace) - PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace) +- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state) +- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state) +- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces) +- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces) - PHP 8.4.15 (feat/005-bulk-operations) @@ -296,9 +300,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` +- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page - 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services -- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` -- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers ### Pre-production compatibility check diff --git a/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php b/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php deleted file mode 100644 index 995cae4e..00000000 --- a/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php +++ /dev/null @@ -1,129 +0,0 @@ -option('tenant'))); - - if ($tenantIdentifiers === []) { - $this->error('Provide one or more tenants via --tenant={id|external_id}.'); - - return self::FAILURE; - } - - $tenants = $this->resolveTenants($tenantIdentifiers); - - if ($tenants->isEmpty()) { - $this->info('No tenants matched the provided identifiers.'); - - return self::SUCCESS; - } - - $queued = 0; - $skipped = 0; - $nothingToDo = 0; - - foreach ($tenants as $tenant) { - if (! $tenant instanceof Tenant) { - continue; - } - - try { - $run = $runbookService->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: null, - reason: null, - source: 'cli', - ); - } catch (OperationalControlBlockedException $e) { - $this->error(sprintf( - 'Backfill paused for tenant %d: %s', - (int) $tenant->getKey(), - $e->getMessage(), - )); - - return self::FAILURE; - } catch (ValidationException $e) { - $errors = $e->errors(); - - if (isset($errors['preflight.affected_count'])) { - $nothingToDo++; - - continue; - } - - $this->error(sprintf( - 'Backfill blocked for tenant %d: %s', - (int) $tenant->getKey(), - $e->getMessage(), - )); - - return self::FAILURE; - } - - if (! $run->wasRecentlyCreated) { - $skipped++; - - continue; - } - - $queued++; - } - - $this->info(sprintf( - 'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.', - $queued, - $skipped, - $nothingToDo, - )); - - return self::SUCCESS; - } - - /** - * @param array $tenantIdentifiers - * @return \Illuminate\Support\Collection - */ - private function resolveTenants(array $tenantIdentifiers) - { - $tenantIds = []; - - foreach ($tenantIdentifiers as $identifier) { - $tenant = Tenant::query() - ->forTenant($identifier) - ->first(); - - if ($tenant instanceof Tenant) { - $tenantIds[] = (int) $tenant->getKey(); - } - } - - $tenantIds = array_values(array_unique($tenantIds)); - - if ($tenantIds === []) { - return collect(); - } - - return Tenant::query() - ->whereIn('id', $tenantIds) - ->orderBy('id') - ->get(); - } -} diff --git a/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php b/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php deleted file mode 100644 index 1bbce8cb..00000000 --- a/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php +++ /dev/null @@ -1,56 +0,0 @@ -start( - scope: FindingsLifecycleBackfillScope::allTenants(), - initiator: null, - reason: new RunbookReason( - reasonCode: RunbookReason::CODE_DATA_REPAIR, - reasonText: 'Deploy hook automated runbooks', - ), - source: 'deploy_hook', - ); - - $this->info('Deploy runbooks started (if needed).'); - - return self::SUCCESS; - } catch (OperationalControlBlockedException $e) { - $this->info('Deploy runbooks paused: '.$e->getMessage()); - - return self::SUCCESS; - } catch (ValidationException $e) { - $errors = $e->errors(); - - $skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']); - - if ($skippable) { - $this->info('Deploy runbooks skipped (nothing to do or already running).'); - - return self::SUCCESS; - } - - $this->error('Deploy runbooks blocked by validation errors.'); - - return self::FAILURE; - } - } -} diff --git a/apps/platform/app/Filament/Pages/Auth/Login.php b/apps/platform/app/Filament/Pages/Auth/Login.php index e8b07ab6..d282cd39 100644 --- a/apps/platform/app/Filament/Pages/Auth/Login.php +++ b/apps/platform/app/Filament/Pages/Auth/Login.php @@ -9,4 +9,9 @@ class Login extends BaseLogin { protected string $view = 'filament.pages.auth.login'; + + public function getTitle(): string + { + return __('localization.auth.sign_in_microsoft'); + } } diff --git a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php new file mode 100644 index 00000000..7069a0ae --- /dev/null +++ b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php @@ -0,0 +1,494 @@ +|null + */ + private ?array $authorizedTenants = null; + + /** + * @var array|null + */ + private ?array $visibleFindingTenants = null; + + /** + * @var array|null + */ + private ?array $reviewTenants = null; + + /** + * @var array|null + */ + private ?array $inboxPayload = null; + + /** + * @var array|null + */ + private ?array $unfilteredInboxPayload = null; + + private ?Workspace $workspace = null; + + private ?bool $visibleAlertsFamily = null; + + public ?int $tenantId = null; + + public ?string $family = null; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.'); + } + + public function mount(): void + { + $this->authorizeWorkspaceMembership(); + $this->applyRequestedTenantPrefilter(); + $this->family = $this->resolveRequestedFamily(); + $this->ensureAtLeastOneVisibleFamily(); + $this->ensureRequestedFamilyIsVisible(); + } + + /** + * @return array + */ + public function appliedScope(): array + { + $selectedTenant = $this->selectedTenant(); + $availableFamilies = collect($this->availableFamilies()) + ->keyBy('key'); + + return [ + 'workspace_label' => $this->workspace()?->name, + 'tenant_label' => $selectedTenant?->name, + 'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none', + 'family_key' => $this->family, + 'family_label' => $this->family !== null + ? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family)) + : 'All attention', + 'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0), + ]; + } + + /** + * @return list + */ + public function availableFamilies(): array + { + return $this->inboxPayload()['available_families'] ?? []; + } + + /** + * @return list> + */ + public function sections(): array + { + return $this->inboxPayload()['sections'] ?? []; + } + + /** + * @return array + */ + public function calmEmptyState(): array + { + if ($this->tenantFilterAloneExcludesRows()) { + return [ + 'title' => 'This tenant filter is hiding other visible attention', + 'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.', + 'action_label' => 'Clear tenant filter', + 'action_url' => $this->pageUrl(['tenant' => null]), + ]; + } + + return [ + 'title' => 'No visible governance attention right now', + 'body' => 'The current workspace scope is calm across the visible governance families.', + 'action_label' => null, + 'action_url' => null, + ]; + } + + public function hasTenantPrefilter(): bool + { + return $this->selectedTenant() instanceof Tenant; + } + + public function isActiveFamily(?string $familyKey): bool + { + return $this->family === $familyKey; + } + + public function pageUrl(array $overrides = []): string + { + $selectedTenant = $this->selectedTenant(); + $resolvedTenant = array_key_exists('tenant', $overrides) + ? $overrides['tenant'] + : ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null); + $resolvedFamily = array_key_exists('family', $overrides) + ? $overrides['family'] + : $this->family; + + return static::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null, + 'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } + + public function navigationContext(): CanonicalNavigationContext + { + return new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), + tenantId: $this->tenantId, + backLinkLabel: 'Back to governance inbox', + backLinkUrl: $this->pageUrl(), + ); + } + + private function authorizeWorkspaceMembership(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + throw new NotFoundHttpException; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + throw new NotFoundHttpException; + } + } + + private function ensureAtLeastOneVisibleFamily(): void + { + if ( + $this->hasVisibleOperationsFamily() + || $this->visibleFindingTenants() !== [] + || $this->reviewTenants() !== [] + || $this->hasVisibleAlertsFamily() + ) { + return; + } + + abort(403); + } + + private function ensureRequestedFamilyIsVisible(): void + { + if ($this->family === null) { + return; + } + + if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) { + return; + } + + throw new NotFoundHttpException; + } + + private function hasVisibleOperationsFamily(): bool + { + return $this->authorizedTenants() !== []; + } + + private function hasVisibleAlertsFamily(): bool + { + if (is_bool($this->visibleAlertsFamily)) { + return $this->visibleAlertsFamily; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->visibleAlertsFamily = false; + } + + return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); + } + + /** + * @return array + */ + private function visibleFindingTenants(): array + { + if ($this->visibleFindingTenants !== null) { + return $this->visibleFindingTenants; + } + + $user = auth()->user(); + $tenants = $this->authorizedTenants(); + + if (! $user instanceof User || $tenants === []) { + return $this->visibleFindingTenants = []; + } + + $resolver = app(CapabilityResolver::class); + $resolver->primeMemberships( + $user, + array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants), + ); + + return $this->visibleFindingTenants = array_values(array_filter( + $tenants, + fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW), + )); + } + + /** + * @return array + */ + private function reviewTenants(): array + { + if ($this->reviewTenants !== null) { + return $this->reviewTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->reviewTenants = []; + } + + $service = app(TenantReviewRegisterService::class); + + if (! $service->canAccessWorkspace($user, $workspace)) { + return $this->reviewTenants = []; + } + + return $this->reviewTenants = $service->authorizedTenants($user, $workspace); + } + + /** + * @return array + */ + private function authorizedTenants(): array + { + if ($this->authorizedTenants !== null) { + return $this->authorizedTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->authorizedTenants = []; + } + + return $this->authorizedTenants = $user->tenants() + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->where('tenants.status', 'active') + ->orderBy('tenants.name') + ->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id']) + ->all(); + } + + private function applyRequestedTenantPrefilter(): void + { + $requestedTenant = request()->query('tenant_id', request()->query('tenant')); + + if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { + return; + } + + foreach ($this->authorizedTenants() as $tenant) { + if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) { + continue; + } + + $this->tenantId = (int) $tenant->getKey(); + + return; + } + + throw new NotFoundHttpException; + } + + private function resolveRequestedFamily(): ?string + { + $family = request()->query('family'); + + if (! is_string($family)) { + return null; + } + + return in_array($family, [ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ], true) ? $family : null; + } + + private function workspace(): ?Workspace + { + if ($this->workspace instanceof Workspace) { + return $this->workspace; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + return $this->workspace = Workspace::query()->whereKey($workspaceId)->first(); + } + + /** + * @return array + */ + private function inboxPayload(): array + { + if (is_array($this->inboxPayload)) { + return $this->inboxPayload; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->inboxPayload = [ + 'sections' => [], + 'available_families' => [], + 'family_counts' => [], + 'total_count' => 0, + ]; + } + + return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: $this->authorizedTenants(), + visibleFindingTenants: $this->visibleFindingTenants(), + reviewTenants: $this->reviewTenants(), + canViewAlerts: $this->hasVisibleAlertsFamily(), + selectedTenant: $this->selectedTenant(), + selectedFamily: $this->family, + navigationContext: $this->navigationContext(), + ); + } + + /** + * @return array + */ + private function unfilteredInboxPayload(): array + { + if (is_array($this->unfilteredInboxPayload)) { + return $this->unfilteredInboxPayload; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->unfilteredInboxPayload = [ + 'sections' => [], + 'available_families' => [], + 'family_counts' => [], + 'total_count' => 0, + ]; + } + + return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: $this->authorizedTenants(), + visibleFindingTenants: $this->visibleFindingTenants(), + reviewTenants: $this->reviewTenants(), + canViewAlerts: $this->hasVisibleAlertsFamily(), + selectedTenant: null, + selectedFamily: null, + navigationContext: $this->navigationContext(), + ); + } + + private function selectedTenant(): ?Tenant + { + if (! is_int($this->tenantId)) { + return null; + } + + foreach ($this->authorizedTenants() as $tenant) { + if ((int) $tenant->getKey() === $this->tenantId) { + return $tenant; + } + } + + return null; + } + + private function tenantFilterAloneExcludesRows(): bool + { + if (! is_int($this->tenantId) || $this->family !== null) { + return false; + } + + if ($this->sections() !== []) { + return false; + } + + return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0; + } +} \ No newline at end of file diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 190a12e7..8b41da58 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -57,6 +57,21 @@ class CustomerReviewWorkspace extends Page implements HasTable protected string $view = 'filament.pages.reviews.customer-review-workspace'; + public static function getNavigationGroup(): string + { + return __('localization.review.reporting'); + } + + public static function getNavigationLabel(): string + { + return __('localization.review.customer_reviews'); + } + + public function getTitle(): string + { + return __('localization.review.customer_review_workspace'); + } + public static function tenantPrefilterUrl(Tenant $tenant): string { $tenantIdentifier = filled($tenant->external_id) @@ -84,7 +99,7 @@ protected function getHeaderActions(): array { return [ Action::make('clear_filters') - ->label('Clear filters') + ->label(__('localization.review.clear_filters')) ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveFilters()) @@ -105,9 +120,9 @@ public function table(Table $table): Table ->persistSortInSession() ->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->columns([ - TextColumn::make('name')->label('Tenant')->searchable()->sortable(), + TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(), TextColumn::make('latest_review') - ->label('Latest review') + ->label(__('localization.review.latest_review')) ->badge() ->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record)) ->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)) @@ -116,25 +131,25 @@ public function table(Table $table): Table ->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record)) ->wrap(), TextColumn::make('finding_summary') - ->label('Key findings') + ->label(__('localization.review.key_findings')) ->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record)) ->wrap(), TextColumn::make('accepted_risk_summary') - ->label('Accepted risks') + ->label(__('localization.review.accepted_risks')) ->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record)) ->wrap(), TextColumn::make('published_at') - ->label('Published') + ->label(__('localization.review.published')) ->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString()) ->dateTime() ->placeholder('—'), TextColumn::make('review_pack_state') - ->label('Review pack') + ->label(__('localization.review.review_pack')) ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)), ]) ->filters([ SelectFilter::make('tenant_id') - ->label('Tenant') + ->label(__('localization.review.tenant')) ->options(fn (): array => $this->tenantFilterOptions()) ->default(fn (): ?string => $this->defaultTenantFilter()) ->query(function (Builder $query, array $data): Builder { @@ -148,25 +163,25 @@ public function table(Table $table): Table ]) ->actions([ Action::make('open_latest_review') - ->label('Open latest review') + ->label(__('localization.review.open_latest_review')) ->icon('heroicon-o-arrow-top-right-on-square') ->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview), Action::make('download_review_pack') - ->label('Download review pack') + ->label(__('localization.review.download_review_pack')) ->icon('heroicon-o-arrow-down-tray') ->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record)) ->openUrlInNewTab() ->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))), ]) ->bulkActions([]) - ->emptyStateHeading('No entitled tenants match this view') + ->emptyStateHeading(__('localization.review.no_entitled_tenants')) ->emptyStateDescription(fn (): string => $this->hasActiveFilters() - ? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.' - : 'Adjust filters to return to the full customer review workspace for your entitled tenants.') + ? __('localization.review.clear_filters_description') + : __('localization.review.adjust_filters_description')) ->emptyStateActions([ Action::make('clear_filters_empty') - ->label('Clear filters') + ->label(__('localization.review.clear_filters')) ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveFilters()) @@ -387,7 +402,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome private function latestReviewStateLabel(Tenant $tenant): string { - return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review'; + return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review'); } private function latestReviewStateColor(Tenant $tenant): string @@ -410,7 +425,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string $review = $this->latestPublishedReview($tenant); if (! $review instanceof TenantReview) { - return 'No published review available yet'; + return __('localization.review.no_published_review_available'); } $primaryReason = $this->reviewOutcome($tenant)?->primaryReason; @@ -427,7 +442,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string return $primaryReason; } - return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); + return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.'); } private function findingSummary(Tenant $tenant): string @@ -435,7 +450,7 @@ private function findingSummary(Tenant $tenant): string $review = $this->latestPublishedReview($tenant); if (! $review instanceof TenantReview) { - return 'No published review available yet'; + return __('localization.review.no_published_review_available'); } $summary = is_array($review->summary) ? $review->summary : []; @@ -444,14 +459,17 @@ private function findingSummary(Tenant $tenant): string $terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes); if ($findingCount === 0) { - return 'No findings recorded in the published review.'; + return __('localization.review.no_findings_recorded'); } if ($terminalOutcomes === null) { - return sprintf('%d findings summarized in the published review.', $findingCount); + return __('localization.review.findings_count_summary', ['count' => $findingCount]); } - return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes); + return __('localization.review.findings_count_with_outcomes', [ + 'count' => $findingCount, + 'outcomes' => $terminalOutcomes, + ]); } private function acceptedRiskSummary(Tenant $tenant): string @@ -459,7 +477,7 @@ private function acceptedRiskSummary(Tenant $tenant): string $review = $this->latestPublishedReview($tenant); if (! $review instanceof TenantReview) { - return 'No published review available yet'; + return __('localization.review.no_published_review_available'); } $summary = is_array($review->summary) ? $review->summary : []; @@ -469,10 +487,10 @@ private function acceptedRiskSummary(Tenant $tenant): string $warningCount = (int) ($riskAcceptance['warning_count'] ?? 0); return match (true) { - $statusMarkedCount === 0 => 'No accepted risks recorded.', - $warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount), - $validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount), - default => sprintf('%d accepted risks are on record.', $statusMarkedCount), + $statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'), + $warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]), + $validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]), + default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]), }; } @@ -481,17 +499,17 @@ private function reviewPackAvailability(Tenant $tenant): string $pack = $this->latestReviewPack($tenant); if (! $pack instanceof ReviewPack) { - return 'Unavailable'; + return __('localization.review.unavailable'); } if ($pack->status !== ReviewPackStatus::Ready->value) { - return 'Unavailable'; + return __('localization.review.unavailable'); } if ($pack->expires_at !== null && $pack->expires_at->isPast()) { - return 'Unavailable'; + return __('localization.review.unavailable'); } - return 'Available'; + return __('localization.review.available'); } -} \ No newline at end of file +} diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php index 79b3831c..ef641a46 100644 --- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php @@ -178,9 +178,23 @@ public function table(Table $table): Table && auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant) && in_array($record->status, ['ready', 'published'], true)) ->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)) - ->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false) - ? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '') - : null) + ->tooltip(function (TenantReview $record): ?string { + $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant); + + if ((bool) ($decision['is_blocked'] ?? false)) { + $reason = $decision['block_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + + if ((bool) ($decision['is_warning'] ?? false)) { + $reason = $decision['warning_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + + return null; + }) ->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)), ]) ->bulkActions([]) diff --git a/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php b/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php index 062d2616..203e3e51 100644 --- a/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php +++ b/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -12,6 +12,7 @@ use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspacePlanProfileCatalog; +use App\Services\Localization\LocaleResolver; use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsWriter; use App\Support\Auth\Capabilities; @@ -58,6 +59,7 @@ class WorkspaceSettings extends Page */ private const SETTING_FIELDS = [ 'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'], + 'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', 'type' => 'string'], 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], @@ -153,17 +155,22 @@ protected function getHeaderActions(): array { return [ Action::make('save') - ->label('Save') + ->label(__('localization.workspace.save')) ->action(function (): void { $this->save(); }) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->tooltip(fn (): ?string => $this->currentUserCanManage() ? null - : 'You do not have permission to manage workspace settings.'), + : __('localization.workspace.no_manage_permission')), ]; } + public function getTitle(): string + { + return __('localization.workspace.title'); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) @@ -208,6 +215,18 @@ public function content(Schema $schema): Schema return $schema ->statePath('data') ->schema([ + Section::make(__('localization.workspace.section')) + ->description($this->sectionDescription('localization', __('localization.workspace.section_description'))) + ->schema([ + Select::make('localization_default_locale') + ->label(__('localization.workspace.default_locale_label')) + ->options(LocaleResolver::localeOptions()) + ->placeholder(__('localization.workspace.default_locale_placeholder')) + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->localeDefaultHelperText()) + ->hintAction($this->makeResetAction('localization_default_locale')), + ]), Section::make('Workspace entitlements') ->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.')) ->columns(2) @@ -507,7 +526,7 @@ public function save(): void $this->loadFormState(); Notification::make() - ->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') + ->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged')) ->success() ->send(); } @@ -526,7 +545,7 @@ public function resetSetting(string $field): void if ($this->workspaceOverrideForField($field) === null) { Notification::make() - ->title('Setting already uses default') + ->title(__('localization.notifications.setting_already_default')) ->success() ->send(); @@ -543,7 +562,7 @@ public function resetSetting(string $field): void $this->loadFormState(); Notification::make() - ->title('Workspace setting reset to default') + ->title(__('localization.notifications.workspace_setting_reset')) ->success() ->send(); } @@ -692,18 +711,17 @@ private function sectionDescription(string $domain, string $baseDescription): st /** @var Carbon $updatedAt */ $updatedAt = $meta['updated_at']; - return sprintf( - '%s — Last modified by %s, %s.', - $baseDescription, - $meta['user_name'], - $updatedAt->diffForHumans(), - ); + return __('localization.workspace.last_modified_by', [ + 'description' => $baseDescription, + 'user' => $meta['user_name'], + 'time' => $updatedAt->diffForHumans(), + ]); } private function makeResetAction(string $field): Action { return Action::make('reset_'.$field) - ->label('Reset') + ->label(__('localization.workspace.reset')) ->color('danger') ->requiresConfirmation() ->action(function () use ($field): void { @@ -718,15 +736,15 @@ private function makeResetAction(string $field): Action ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field)) ->tooltip(function () use ($field): ?string { if (! $this->currentUserCanManage()) { - return 'You do not have permission to manage workspace settings.'; + return __('localization.workspace.no_manage_permission'); } if (! $this->canResetField($field)) { if ($this->isEntitlementOverrideValueField($field)) { - return 'No workspace override to reset.'; + return __('localization.workspace.no_workspace_override'); } - return 'No workspace override to reset.'; + return __('localization.workspace.no_workspace_override'); } return null; @@ -948,6 +966,29 @@ private function helperTextFor(string $field): string return sprintf('Effective value: %s.', $effectiveValue); } + private function localeDefaultHelperText(): string + { + $resolved = $this->resolvedSettings['localization_default_locale'] ?? null; + + if (! is_array($resolved)) { + return ''; + } + + $effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en'; + $localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale); + + if (! $this->hasWorkspaceOverride('localization_default_locale')) { + return __('localization.workspace.default_locale_helper_unset', [ + 'locale' => $localeLabel, + 'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')), + ]); + } + + return __('localization.workspace.default_locale_helper_set', [ + 'locale' => $localeLabel, + ]); + } + private function slaFieldHelperText(string $severity): string { $resolved = $this->resolvedSettings['findings_sla_days'] ?? null; @@ -1353,9 +1394,9 @@ private function formatValueForDisplay(string $field, mixed $value): string private function sourceLabel(string $source): string { return match ($source) { - 'workspace_override' => 'workspace override', + 'workspace_override' => __('localization.source.workspace_override'), 'tenant_override' => 'tenant override', - default => 'system default', + default => __('localization.source.system_default'), }; } diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 7d29d148..8af8dbe7 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -42,6 +42,11 @@ class TenantDashboard extends Dashboard */ public array $supportDiagnosticsAuditKeys = []; + public function getTitle(): string + { + return __('localization.dashboard.tenant_title'); + } + /** * @param array $parameters */ @@ -90,38 +95,38 @@ public function authorizeTenantSupportRequest(): void private function requestSupportAction(): Action { $action = Action::make('requestSupport') - ->label('Request support') + ->label(__('localization.dashboard.request_support')) ->icon('heroicon-o-paper-airplane') ->color('gray') ->slideOver() ->stickyModalHeader() - ->modalHeading('Request support') - ->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.') - ->modalSubmitActionLabel('Submit request') + ->modalHeading(__('localization.dashboard.support_request_heading')) + ->modalDescription(__('localization.dashboard.support_request_description')) + ->modalSubmitActionLabel(__('localization.dashboard.submit_request')) ->form([ Placeholder::make('included_context') - ->label('Included context') + ->label(__('localization.dashboard.included_context')) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->columnSpanFull(), Select::make('severity') - ->label('Severity') + ->label(__('localization.dashboard.severity')) ->options(SupportRequest::severityOptions()) ->default(SupportRequest::SEVERITY_NORMAL) ->required() ->native(false), TextInput::make('summary') - ->label('Summary') + ->label(__('localization.dashboard.summary')) ->required() ->columnSpanFull(), Textarea::make('reproduction_notes') - ->label('Reproduction notes') + ->label(__('localization.dashboard.reproduction_notes')) ->rows(4) ->columnSpanFull(), TextInput::make('contact_name') - ->label('Contact name') + ->label(__('localization.dashboard.contact_name')) ->default(fn (): ?string => $this->resolveDashboardActor()->name), TextInput::make('contact_email') - ->label('Contact email') + ->label(__('localization.dashboard.contact_email')) ->email() ->default(fn (): ?string => $this->resolveDashboardActor()->email), ]) @@ -132,7 +137,7 @@ private function requestSupportAction(): Action $supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data); Notification::make() - ->title('Support request submitted') + ->title(__('localization.dashboard.support_request_submitted')) ->body('Reference '.$supportRequest->internal_reference) ->success() ->send(); @@ -146,16 +151,16 @@ private function requestSupportAction(): Action private function openSupportDiagnosticsAction(): Action { $action = Action::make('openSupportDiagnostics') - ->label('Open support diagnostics') + ->label(__('localization.dashboard.open_support_diagnostics')) ->icon('heroicon-o-lifebuoy') ->color('gray') ->modal() ->slideOver() ->stickyModalHeader() - ->modalHeading('Support diagnostics') - ->modalDescription('Redacted tenant context from existing records.') + ->modalHeading(__('localization.dashboard.support_diagnostics')) + ->modalDescription(__('localization.dashboard.support_diagnostics_description')) ->modalSubmitAction(false) - ->modalCancelAction(fn (Action $action): Action => $action->label('Close')) + ->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close'))) ->mountUsing(function (): void { $this->auditTenantSupportDiagnosticsOpen(); }) diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index d95e2623..3127b1ff 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -30,6 +30,7 @@ use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftStageResolver; use App\Services\Onboarding\OnboardingLifecycleService; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\OperationRunService; use App\Services\Providers\ProviderConnectionMutationService; @@ -4551,27 +4552,30 @@ private function completionSummaryEntitlementDecision(): array return []; } - return app(WorkspaceEntitlementResolver::class)->resolve( + return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision( $this->workspace, - WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, + WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION, ); } private function completionSummaryEntitlementBlocked(): bool { - return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false); + return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK; } private function completionSummaryEntitlementSummary(): string { $decision = $this->completionSummaryEntitlementDecision(); - $currentUsage = (int) ($decision['current_usage'] ?? 0); - $effectiveValue = (int) ($decision['effective_value'] ?? 0); - $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision); + $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; + $currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); + $effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); + $sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision); + $stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid'; return sprintf( - '%s - %d active of %d allowed (%s)', + '%s - %s - %d active of %d allowed (%s)', $this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed', + $stateLabel, $currentUsage, $effectiveValue, $sourceLabel, @@ -4581,13 +4585,15 @@ private function completionSummaryEntitlementSummary(): string private function completionSummaryEntitlementDetail(): string { $decision = $this->completionSummaryEntitlementDecision(); - $currentUsage = (int) ($decision['current_usage'] ?? 0); - $effectiveValue = (int) ($decision['effective_value'] ?? 0); - $remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0); - $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision); + $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; + $currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); + $effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); + $remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0); + $sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision); $rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null; $message = sprintf( - 'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.', + '%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.', + (string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'), $currentUsage, $currentUsage === 1 ? '' : 's', $effectiveValue, @@ -4606,7 +4612,7 @@ private function completionSummaryEntitlementDetail(): string } } - if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') { + if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) { $message .= ' Rationale: '.$rationale; } @@ -4982,7 +4988,7 @@ public function completeOnboarding(): void if ($this->completionSummaryEntitlementBlocked()) { Notification::make() - ->title('Activation limit reached') + ->title('Activation unavailable') ->body($this->completionSummaryEntitlementDetail()) ->warning() ->send(); diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index 776a1a62..d7e1f83e 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -75,8 +75,6 @@ class FindingResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Governance'; - protected static ?string $navigationLabel = 'Findings'; - public static function shouldRegisterNavigation(): bool { if (Filament::getCurrentPanel()?->getId() === 'admin') { @@ -86,6 +84,26 @@ public static function shouldRegisterNavigation(): bool return parent::shouldRegisterNavigation(); } + public static function getNavigationLabel(): string + { + return __('localization.navigation.findings'); + } + + public static function getNavigationGroup(): string + { + return __('localization.navigation.governance'); + } + + public static function getModelLabel(): string + { + return __('localization.navigation.findings'); + } + + public static function getPluralModelLabel(): string + { + return __('localization.navigation.findings'); + } + public static function canViewAny(): bool { $tenant = static::resolveTenantContextForCurrentPanel(); diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php index d1b19989..d4796c04 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -10,14 +10,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Findings\FindingWorkflowService; -use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; -use App\Services\Runbooks\FindingsLifecycleBackfillScope; use App\Support\Auth\Capabilities; use App\Support\Filament\CanonicalAdminTenantFilterState; -use App\Support\OperationRunLinks; -use App\Support\OpsUx\OperationUxPresenter; -use App\Support\OpsUx\OpsUxBrowserEvents; -use App\Support\OperationalControls\OperationalControlBlockedException; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; use Filament\Actions; @@ -77,15 +71,15 @@ public function getTabs(): array $stats = FindingResource::findingStatsForCurrentTenant(); return [ - 'all' => Tab::make('All') + 'all' => Tab::make(__('localization.findings.all')) ->icon('heroicon-m-list-bullet'), - 'needs_action' => Tab::make('Needs action') + 'needs_action' => Tab::make(__('localization.findings.needs_action')) ->icon('heroicon-m-exclamation-triangle') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->whereIn('status', Finding::openStatusesForQuery())) ->badge($stats['open'] > 0 ? $stats['open'] : null) ->badgeColor('warning'), - 'overdue' => Tab::make('Overdue') + 'overdue' => Tab::make(__('localization.findings.overdue')) ->icon('heroicon-m-clock') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->whereIn('status', Finding::openStatusesForQuery()) @@ -93,11 +87,11 @@ public function getTabs(): array ->where('due_at', '<', now())) ->badge($stats['overdue'] > 0 ? $stats['overdue'] : null) ->badgeColor('danger'), - 'risk_accepted' => Tab::make('Risk accepted') + 'risk_accepted' => Tab::make(__('localization.findings.risk_accepted')) ->icon('heroicon-m-shield-check') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->where('status', Finding::STATUS_RISK_ACCEPTED)), - 'resolved' => Tab::make('Resolved') + 'resolved' => Tab::make(__('localization.findings.resolved')) ->icon('heroicon-m-archive-box') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])), @@ -108,77 +102,6 @@ protected function getHeaderActions(): array { $actions = []; - $actions[] = UiEnforcement::forAction( - Actions\Action::make('backfill_lifecycle') - ->label('Backfill findings lifecycle') - ->icon('heroicon-o-wrench-screwdriver') - ->color('gray') - ->requiresConfirmation() - ->modalHeading('Backfill findings lifecycle') - ->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.') - ->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - $tenant = static::resolveTenantContextForCurrentPanel(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - try { - $opRun = $runbookService->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: $user, - reason: null, - source: 'tenant_ui', - ); - } catch (OperationalControlBlockedException $exception) { - Notification::make() - ->title($exception->title()) - ->body($exception->getMessage()) - ->warning() - ->send(); - - throw new \Filament\Support\Exceptions\Halt; - } - - $runUrl = OperationRunLinks::view($opRun, $tenant); - - if ($opRun->wasRecentlyCreated === false) { - OpsUxBrowserEvents::dispatchRunEnqueued($this); - - OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url($runUrl), - ]) - ->send(); - - return; - } - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->body('The backfill will run in the background. You can continue working while it completes.') - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url($runUrl), - ]) - ->send(); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_MANAGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(); - $actions[] = UiEnforcement::forAction( Actions\Action::make('triage_all_matching') ->label('Triage all matching') diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php index b89e3f30..ab990e4c 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php @@ -44,7 +44,7 @@ protected function getHeaderActions(): array ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false)) ->color('gray'), Actions\Action::make('open_approval_queue') - ->label('Open approval queue') + ->label(__('localization.findings.open_approval_queue')) ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->visible(function (): bool { @@ -61,7 +61,7 @@ protected function getHeaderActions(): array : null; }), Actions\ActionGroup::make(FindingResource::workflowActions()) - ->label('Actions') + ->label(__('localization.findings.actions')) ->icon('heroicon-o-ellipsis-vertical') ->color('gray'), ]); diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource.php b/apps/platform/app/Filament/Resources/ReviewPackResource.php index 86e26d67..68fd4537 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource.php @@ -575,6 +575,19 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): return is_string($reason) && $reason !== '' ? $reason : null; } + public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string + { + $decision = static::reviewPackGenerationDecision($tenant); + + if (! (bool) ($decision['is_warning'] ?? false)) { + return null; + } + + $reason = $decision['warning_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string { $tenant ??= static::currentTenantContext(); @@ -584,6 +597,7 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null) return AuthUiTooltips::insufficientPermission(); } - return static::reviewPackGenerationBlockReason($tenant); + return static::reviewPackGenerationBlockReason($tenant) + ?? static::reviewPackGenerationWarningReason($tenant); } } diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index 8ae2220e..da7a53f1 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -85,6 +85,26 @@ public static function shouldRegisterNavigation(): bool return Filament::getCurrentPanel()?->getId() === 'tenant'; } + public static function getNavigationGroup(): string + { + return __('localization.review.reporting'); + } + + public static function getNavigationLabel(): string + { + return __('localization.review.reviews'); + } + + public static function getModelLabel(): string + { + return __('localization.review.review'); + } + + public static function getPluralModelLabel(): string + { + return __('localization.review.reviews'); + } + public static function canViewAny(): bool { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -153,7 +173,7 @@ public static function form(Schema $schema): Schema public static function infolist(Schema $schema): Schema { return $schema->schema([ - Section::make('Outcome summary') + Section::make(__('localization.review.outcome_summary')) ->schema([ ViewEntry::make('artifact_truth') ->hiddenLabel() @@ -162,7 +182,7 @@ public static function infolist(Schema $schema): Schema ->columnSpanFull(), ]) ->columnSpanFull(), - Section::make('Review') + Section::make(__('localization.review.review')) ->schema([ TextEntry::make('status') ->badge() @@ -171,23 +191,23 @@ public static function infolist(Schema $schema): Schema ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), TextEntry::make('completeness_state') - ->label('Completeness') + ->label(__('localization.review.completeness')) ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), - TextEntry::make('tenant.name')->label('Tenant'), + TextEntry::make('tenant.name')->label(__('localization.review.tenant')), TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('published_at')->dateTime()->placeholder('—'), TextEntry::make('evidenceSnapshot.id') - ->label('Evidence snapshot') + ->label(__('localization.review.evidence_snapshot')) ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) : null), TextEntry::make('currentExportReviewPack.id') - ->label('Current export') + ->label(__('localization.review.current_export')) ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack ? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant) @@ -201,7 +221,7 @@ public static function infolist(Schema $schema): Schema ]) ->columns(2) ->columnSpanFull(), - Section::make('Executive posture') + Section::make(__('localization.review.executive_posture')) ->schema([ ViewEntry::make('review_summary') ->hiddenLabel() @@ -210,21 +230,21 @@ public static function infolist(Schema $schema): Schema ->columnSpanFull(), ]) ->columnSpanFull(), - Section::make('Sections') + Section::make(__('localization.review.sections')) ->schema([ RepeatableEntry::make('sections') ->hiddenLabel() ->schema([ TextEntry::make('title'), TextEntry::make('completeness_state') - ->label('Completeness') + ->label(__('localization.review.completeness')) ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), TextEntry::make('measured_at')->dateTime()->placeholder('—'), - Section::make('Details') + Section::make(__('localization.review.details')) ->schema([ ViewEntry::make('section_payload') ->hiddenLabel() @@ -246,7 +266,7 @@ public static function table(Table $table): Table { $exportExecutivePackAction = UiEnforcement::forTableAction( Actions\Action::make('export_executive_pack') - ->label('Export executive pack') + ->label(__('localization.review.export_executive_pack')) ->icon('heroicon-o-arrow-down-tray') ->visible(fn (TenantReview $record): bool => in_array($record->status, [ TenantReviewStatus::Ready->value, @@ -278,7 +298,7 @@ public static function table(Table $table): Table ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->sortable(), Tables\Columns\TextColumn::make('outcome') - ->label('Outcome') + ->label(__('localization.review.outcome')) ->badge() ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel) ->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color) @@ -289,10 +309,10 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\IconColumn::make('summary.has_ready_export') - ->label('Export') + ->label(__('localization.review.export')) ->boolean(), Tables\Columns\TextColumn::make('next_step') - ->label('Next step') + ->label(__('localization.review.next_step')) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText) ->wrap(), Tables\Columns\TextColumn::make('fingerprint') @@ -306,18 +326,18 @@ public static function table(Table $table): Table ->all()), Tables\Filters\SelectFilter::make('completeness_state') ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())), - \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), + \App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'), ]) ->actions([ $exportExecutivePackAction, ]) ->bulkActions([]) - ->emptyStateHeading('No tenant reviews yet') - ->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.') + ->emptyStateHeading(__('localization.review.no_tenant_reviews_yet')) + ->emptyStateDescription(__('localization.review.create_first_review_description')) ->emptyStateActions([ static::makeCreateReviewAction( name: 'create_first_review', - label: 'Create first review', + label: __('localization.review.create_first_review'), icon: 'heroicon-o-plus', ), ]); @@ -336,19 +356,23 @@ public static function makeCreateReviewAction( string $label = 'Create review', string $icon = 'heroicon-o-plus', ): Actions\Action { + $label = $label === 'Create review' + ? __('localization.review.create_review') + : $label; + return UiEnforcement::forAction( Actions\Action::make($name) ->label($label) ->icon($icon) ->form([ - Section::make('Evidence basis') + Section::make(__('localization.review.evidence_basis')) ->schema([ Select::make('evidence_snapshot_id') - ->label('Evidence snapshot') + ->label(__('localization.review.evidence_snapshot')) ->required() ->options(fn (): array => static::evidenceSnapshotOptions()) ->searchable() - ->helperText('Choose the anchored evidence snapshot for this review.'), + ->helperText(__('localization.review.evidence_basis_helper')), ]), ]) ->action(fn (array $data): mixed => static::executeCreateReview($data)), @@ -366,7 +390,7 @@ public static function executeCreateReview(array $data): void $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { - Notification::make()->danger()->title('Unable to create review — missing context.')->send(); + Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send(); return; } @@ -388,7 +412,7 @@ public static function executeCreateReview(array $data): void : null; if (! $snapshot instanceof EvidenceSnapshot) { - Notification::make()->danger()->title('Select a valid evidence snapshot.')->send(); + Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send(); return; } @@ -396,7 +420,7 @@ public static function executeCreateReview(array $data): void try { $review = app(TenantReviewService::class)->create($tenant, $snapshot, $user); } catch (\Throwable $throwable) { - Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send(); + Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send(); return; } @@ -406,11 +430,11 @@ public static function executeCreateReview(array $data): void if (! $review->wasRecentlyCreated) { Notification::make() ->success() - ->title('Review already available') - ->body('A matching mutable review already exists for this evidence basis.') + ->title(__('localization.review.review_already_available')) + ->body(__('localization.review.review_already_available_body')) ->actions([ Actions\Action::make('view_review') - ->label('View review') + ->label(__('localization.review.view_review')) ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ]) ->send(); @@ -419,12 +443,12 @@ public static function executeCreateReview(array $data): void } $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) - ->body('The review is being composed in the background.'); + ->body(__('localization.review.review_composing_background')); if ($review->operation_run_id) { $toast->actions([ Actions\Action::make('view_run') - ->label('Open operation') + ->label(__('localization.review.open_operation')) ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ]); } @@ -464,6 +488,19 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): return is_string($reason) && $reason !== '' ? $reason : null; } + public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string + { + $decision = static::reviewPackGenerationDecision($tenant); + + if (! (bool) ($decision['is_warning'] ?? false)) { + return null; + } + + $reason = $decision['warning_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string { $tenant ??= static::panelTenantContext(); @@ -473,7 +510,8 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null) return AuthUiTooltips::insufficientPermission(); } - return static::reviewPackGenerationBlockReason($tenant); + return static::reviewPackGenerationBlockReason($tenant) + ?? static::reviewPackGenerationWarningReason($tenant); } public static function executeExport(TenantReview $review): void @@ -482,7 +520,7 @@ public static function executeExport(TenantReview $review): void $user = auth()->user(); if (! $user instanceof User || ! $review->tenant instanceof Tenant) { - Notification::make()->danger()->title('Unable to export review — missing context.')->send(); + Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send(); return; } @@ -499,7 +537,7 @@ public static function executeExport(TenantReview $review): void if ($service->checkActiveRunForReview($review)) { OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value) - ->body('An executive pack export is already queued or running for this review.') + ->body(__('localization.review.export_already_queued_body')) ->send(); return; @@ -511,11 +549,11 @@ public static function executeExport(TenantReview $review): void 'include_operations' => true, ]); } catch (WorkspaceEntitlementBlockedException $exception) { - Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send(); + Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send(); return; } catch (\Throwable $throwable) { - Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send(); + Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send(); return; } @@ -526,11 +564,11 @@ public static function executeExport(TenantReview $review): void if (! $pack->wasRecentlyCreated) { Notification::make() ->success() - ->title('Executive pack already available') - ->body('A matching executive pack already exists for this review.') + ->title(__('localization.review.executive_pack_already_available')) + ->body(__('localization.review.executive_pack_already_available_body')) ->actions([ Actions\Action::make('view_pack') - ->label('View pack') + ->label(__('localization.review.view_pack')) ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ]) ->send(); @@ -539,7 +577,7 @@ public static function executeExport(TenantReview $review): void } OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) - ->body('The executive pack is being generated in the background.') + ->body(__('localization.review.executive_pack_generating_background')) ->send(); } @@ -579,7 +617,7 @@ private static function evidenceSnapshotOptions(): array '#%d · %s · %s', (int) $snapshot->getKey(), BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label, - $snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending' + $snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending') ), ]) ->all(); @@ -603,7 +641,7 @@ private static function summaryPresentation(TenantReview $record): array $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; if ($findingOutcomeSummary !== null) { - $highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.'; + $highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.'; } return [ @@ -615,12 +653,12 @@ private static function summaryPresentation(TenantReview $record): array 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'context_links' => static::summaryContextLinks($record), 'metrics' => [ - ['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], - ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], - ['label' => 'Operations', 'value' => (string) ($summary['operation_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)], + ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)], + ['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)], + ['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)], + ['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)], + ['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], + ['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ], ]; } @@ -634,37 +672,37 @@ private static function summaryContextLinks(TenantReview $record): array if (is_numeric($record->operation_run_id)) { $links[] = [ - 'title' => 'Operation', - 'label' => 'Open operation', + 'title' => __('localization.review.operation'), + 'label' => __('localization.review.open_operation'), 'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id), - 'description' => 'Inspect the latest review composition or refresh run.', + 'description' => __('localization.review.operation_description'), ]; } if ($record->currentExportReviewPack && $record->tenant) { $links[] = [ - 'title' => 'Executive pack', - 'label' => 'View executive pack', + 'title' => __('localization.review.executive_pack'), + 'label' => __('localization.review.view_executive_pack'), 'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant), - 'description' => 'Open the current export that belongs to this review.', + 'description' => __('localization.review.executive_pack_description'), ]; } if ($record->tenant) { $links[] = [ - 'title' => 'Customer workspace', - 'label' => 'Open customer workspace', + 'title' => __('localization.review.customer_workspace'), + 'label' => __('localization.review.open_customer_workspace'), 'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), - 'description' => 'Open the customer-safe review workspace prefiltered to this tenant.', + 'description' => __('localization.review.customer_workspace_description'), ]; } if ($record->evidenceSnapshot && $record->tenant) { $links[] = [ - 'title' => 'Evidence snapshot', - 'label' => 'View evidence snapshot', + 'title' => __('localization.review.evidence_snapshot'), + 'label' => __('localization.review.view_evidence_snapshot'), 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), - 'description' => 'Return to the evidence basis behind this review.', + 'description' => __('localization.review.evidence_snapshot_description'), ]; } diff --git a/apps/platform/app/Filament/System/Pages/Dashboard.php b/apps/platform/app/Filament/System/Pages/Dashboard.php index 2c238340..03534ef6 100644 --- a/apps/platform/app/Filament/System/Pages/Dashboard.php +++ b/apps/platform/app/Filament/System/Pages/Dashboard.php @@ -28,6 +28,11 @@ class Dashboard extends BaseDashboard { public string $window = SystemConsoleWindow::LastDay; + public function getTitle(): string + { + return __('localization.dashboard.system_title'); + } + /** * @param array $parameters */ @@ -109,12 +114,12 @@ protected function getHeaderActions(): array return [ Action::make('set_window') - ->label('Time window') + ->label(__('localization.dashboard.time_window')) ->icon('heroicon-o-clock') ->color('gray') ->form([ Select::make('window') - ->label('Window') + ->label(__('localization.dashboard.window')) ->options(SystemConsoleWindow::options()) ->default($this->window) ->required(), @@ -130,7 +135,7 @@ protected function getHeaderActions(): array }), Action::make('enter_break_glass') - ->label('Enter break-glass mode') + ->label(__('localization.dashboard.enter_break_glass')) ->color('danger') ->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive()) ->requiresConfirmation() @@ -158,13 +163,13 @@ protected function getHeaderActions(): array $breakGlass->start($user, (string) ($data['reason'] ?? '')); Notification::make() - ->title('Recovery mode enabled') + ->title(__('localization.dashboard.recovery_mode_enabled')) ->success() ->send(); }), Action::make('exit_break_glass') - ->label('Exit break-glass') + ->label(__('localization.dashboard.exit_break_glass')) ->color('gray') ->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive()) ->requiresConfirmation() @@ -180,7 +185,7 @@ protected function getHeaderActions(): array $breakGlass->exit($user); Notification::make() - ->title('Recovery mode ended') + ->title(__('localization.dashboard.recovery_mode_ended')) ->success() ->send(); }), diff --git a/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php b/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php index 1ba62715..38218989 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php +++ b/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php @@ -9,13 +9,19 @@ use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\Workspace; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; +use App\Services\Settings\SettingsWriter; use App\Support\Auth\PlatformCapabilities; use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery; use App\Support\OperationCatalog; use App\Support\System\SystemDirectoryLinks; use App\Support\System\SystemOperationRunLinks; use App\Support\SystemConsole\SystemConsoleWindow; +use Filament\Actions\Action; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Support\Collection; @@ -94,6 +100,77 @@ public function workspaceEntitlementSummary(): array return app(WorkspaceEntitlementResolver::class)->summary($this->workspace); } + /** + * @return array + */ + public function workspaceCommercialLifecycleSummary(): array + { + return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + Action::make('change_commercial_state') + ->label('Change commercial state') + ->icon('heroicon-o-adjustments-horizontal') + ->color('warning') + ->visible(fn (): bool => $this->canManageCommercialLifecycle()) + ->requiresConfirmation() + ->modalHeading('Change commercial state') + ->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.') + ->form([ + Select::make('state') + ->label('Commercial state') + ->options(WorkspaceCommercialLifecycleResolver::stateLabels()) + ->required() + ->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)), + Textarea::make('reason') + ->label('Rationale') + ->required() + ->minLength(5) + ->maxLength(500) + ->rows(4), + ]) + ->action(function (array $data, SettingsWriter $settingsWriter): void { + $actor = auth('platform')->user(); + + if (! $actor instanceof PlatformUser) { + abort(403); + } + + if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) { + abort(403); + } + + $settingsWriter->updateWorkspaceCommercialLifecycle( + actor: $actor, + workspace: $this->workspace, + state: (string) ($data['state'] ?? ''), + reason: (string) ($data['reason'] ?? ''), + ); + + $this->workspace->refresh(); + + Notification::make() + ->title('Commercial state updated') + ->success() + ->send(); + }), + ]; + } + + private function canManageCommercialLifecycle(): bool + { + $user = auth('platform')->user(); + + return $user instanceof PlatformUser + && $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE); + } + /** * @return array{ * overall: array{label: string, color: string, icon: string|null}, diff --git a/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php b/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php index 89cc1c02..ed23b75d 100644 --- a/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php +++ b/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php @@ -4,26 +4,9 @@ namespace App\Filament\System\Pages\Ops; -use App\Models\OperationRun; use App\Models\PlatformUser; -use App\Models\Tenant; -use App\Services\Auth\BreakGlassSession; -use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; -use App\Services\Runbooks\FindingsLifecycleBackfillScope; -use App\Services\Runbooks\RunbookReason; -use App\Services\System\AllowedTenantUniverse; use App\Support\Auth\PlatformCapabilities; -use App\Support\OpsUx\OperationUxPresenter; -use App\Support\OperationalControls\OperationalControlBlockedException; -use App\Support\System\SystemOperationRunLinks; -use Filament\Actions\Action; -use Filament\Forms\Components\Radio; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Notifications\Notification; use Filament\Pages\Page; -use Illuminate\Validation\ValidationException; class Runbooks extends Page { @@ -37,53 +20,6 @@ class Runbooks extends Page protected string $view = 'filament.system.pages.ops.runbooks'; - public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS; - - public ?int $findingsTenantId = null; - - public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS; - - public ?int $tenantId = null; - - /** - * @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null - */ - public ?array $findingsPreflight = null; - - /** - * @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null - */ - public ?array $preflight = null; - - public function findingsScopeLabel(): string - { - if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) { - return 'All tenants'; - } - - $tenantName = $this->selectedTenantName($this->findingsTenantId); - - if ($tenantName !== null) { - return "Single tenant ({$tenantName})"; - } - - return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant'; - } - - public function findingsLastRun(): ?OperationRun - { - return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY); - } - - public function selectedTenantName(?int $tenantId): ?string - { - if ($tenantId === null) { - return null; - } - - return Tenant::query()->whereKey($tenantId)->value('name'); - } - public static function canAccess(): bool { $user = auth('platform')->user(); @@ -95,231 +31,4 @@ public static function canAccess(): bool return $user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW); } - - /** - * @return array - */ - protected function getHeaderActions(): array - { - return [ - Action::make('preflight') - ->label('Preflight') - ->color('gray') - ->icon('heroicon-o-magnifying-glass') - ->form($this->findingsScopeForm()) - ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { - $scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class)); - - $this->findingsScopeMode = $scope->mode; - $this->findingsTenantId = $scope->tenantId; - $this->scopeMode = $scope->mode; - $this->tenantId = $scope->tenantId; - - $this->findingsPreflight = $runbookService->preflight($scope); - $this->preflight = $this->findingsPreflight; - - Notification::make() - ->title('Preflight complete') - ->success() - ->send(); - }), - - Action::make('run') - ->label('Run…') - ->icon('heroicon-o-play') - ->color('danger') - ->requiresConfirmation() - ->modalHeading('Run: Rebuild Findings Lifecycle') - ->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.') - ->form($this->findingsRunForm()) - ->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) - ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { - if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) { - throw ValidationException::withMessages([ - 'preflight' => 'Run preflight first.', - ]); - } - - $scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class)); - - $user = auth('platform')->user(); - - if (! $user instanceof PlatformUser) { - abort(403); - } - - if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN) - || ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL) - ) { - abort(403); - } - - if ($scope->isAllTenants()) { - $typedConfirmation = (string) ($data['typed_confirmation'] ?? ''); - - if ($typedConfirmation !== 'BACKFILL') { - throw ValidationException::withMessages([ - 'typed_confirmation' => 'Please type BACKFILL to confirm.', - ]); - } - } - - $reason = RunbookReason::fromNullableArray([ - 'reason_code' => $data['reason_code'] ?? null, - 'reason_text' => $data['reason_text'] ?? null, - ]); - - try { - $run = $runbookService->start( - scope: $scope, - initiator: $user, - reason: $reason, - source: 'system_ui', - ); - } catch (OperationalControlBlockedException $exception) { - Notification::make() - ->title($exception->title()) - ->body($exception->getMessage()) - ->warning() - ->send(); - - throw new \Filament\Support\Exceptions\Halt; - } - - $viewUrl = SystemOperationRunLinks::view($run); - - $toast = $run->wasRecentlyCreated - ? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.') - : OperationUxPresenter::alreadyQueuedToast((string) $run->type); - - $toast - ->actions([ - Action::make('view_run') - ->label('View run') - ->url($viewUrl), - ]) - ->send(); - }), - ]; - } - - /** - * @return array - */ - private function findingsScopeForm(): array - { - return [ - Radio::make('scope_mode') - ->label('Scope') - ->options([ - FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants', - FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant', - ]) - ->default($this->findingsScopeMode) - ->live() - ->required(), - - Select::make('tenant_id') - ->label('Tenant') - ->searchable() - ->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array { - return $universe - ->query() - ->where('name', 'like', "%{$search}%") - ->orderBy('name') - ->limit(25) - ->pluck('name', 'id') - ->all(); - }) - ->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string { - if (! is_numeric($value)) { - return null; - } - - return $universe - ->query() - ->whereKey((int) $value) - ->value('name'); - }), - ]; - } - - /** - * @return array - */ - private function findingsRunForm(): array - { - return [ - TextInput::make('typed_confirmation') - ->label('Type BACKFILL to confirm') - ->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->in(['BACKFILL']) - ->validationMessages([ - 'in' => 'Please type BACKFILL to confirm.', - ]), - - Select::make('reason_code') - ->label('Reason code') - ->options(RunbookReason::options()) - ->required(function (BreakGlassSession $breakGlass): bool { - return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive(); - }), - - Textarea::make('reason_text') - ->label('Reason') - ->rows(4) - ->maxLength(500) - ->required(function (BreakGlassSession $breakGlass): bool { - return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive(); - }), - ]; - } - - private function lastRunForType(string $type): ?OperationRun - { - $platformTenant = Tenant::query()->where('external_id', 'platform')->first(); - - if (! $platformTenant instanceof Tenant) { - return null; - } - - return OperationRun::query() - ->where('workspace_id', (int) $platformTenant->workspace_id) - ->where('type', $type) - ->latest('id') - ->first(); - } - - /** - * @param array $data - */ - private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope - { - $scope = FindingsLifecycleBackfillScope::fromArray([ - 'mode' => $data['scope_mode'] ?? null, - 'tenant_id' => $data['tenant_id'] ?? null, - ]); - - if (! $scope->isSingleTenant()) { - return $scope; - } - - $tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId); - - return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()); - } - - private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope - { - if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) { - return FindingsLifecycleBackfillScope::allTenants(); - } - - $tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId); - - return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()); - } } diff --git a/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php b/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php index 5052d05b..9eb73e28 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php +++ b/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php @@ -81,6 +81,14 @@ public function generatePack(bool $includePii = true, bool $includeOperations = return; } + if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) { + Notification::make() + ->title('Review pack generation allowed with warning') + ->body($decision['warning_reason']) + ->warning() + ->send(); + } + $activeRun = $service->checkActiveRun($tenant) ? OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) @@ -163,6 +171,9 @@ protected function getViewData(): array $generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null) ? $generationEntitlement['block_reason'] : null; + $generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null) + ? $generationEntitlement['warning_reason'] + : null; $latestPack = ReviewPack::query() ->with(['tenantReview', 'operationRun']) @@ -181,6 +192,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'generationBlocked' => $generationBlocked, 'generationBlockReason' => $generationBlockReason, + 'generationWarningReason' => $generationWarningReason, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'downloadUrl' => null, 'failedReason' => null, @@ -232,6 +244,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'generationBlocked' => $generationBlocked, 'generationBlockReason' => $generationBlockReason, + 'generationWarningReason' => $generationWarningReason, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'downloadUrl' => $downloadUrl, 'failedReason' => $failedReason, @@ -265,6 +278,7 @@ private function emptyState(): array 'canManage' => false, 'generationBlocked' => false, 'generationBlockReason' => null, + 'generationWarningReason' => null, 'customerWorkspaceUrl' => null, 'downloadUrl' => null, 'failedReason' => null, diff --git a/apps/platform/app/Http/Controllers/LocalizationController.php b/apps/platform/app/Http/Controllers/LocalizationController.php new file mode 100644 index 00000000..feab7fe3 --- /dev/null +++ b/apps/platform/app/Http/Controllers/LocalizationController.php @@ -0,0 +1,80 @@ +query('plane'); + $context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE); + + if (is_string($plane) && $plane !== '') { + $context = $resolver->resolve($request, $plane); + } + + return response()->json(is_array($context) ? $context : $resolver->resolve($request)); + } + + public function updateOverride(Request $request): RedirectResponse + { + $locale = LocaleResolver::normalize($request->input('locale')); + + if ($locale === null) { + throw ValidationException::withMessages([ + 'locale' => [__('localization.validation.unsupported_locale')], + ]); + } + + $request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale); + App::setLocale($locale); + + return back()->with('status', __('localization.notifications.locale_override_saved')); + } + + public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse + { + $request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY); + App::setLocale($resolver->resolve($request)['locale']); + + return back()->with('status', __('localization.notifications.locale_override_cleared')); + } + + public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse + { + $user = $request->user(); + + abort_unless($user instanceof User, Response::HTTP_NOT_FOUND); + + $rawLocale = $request->input('preferred_locale'); + $locale = $rawLocale === null || $rawLocale === '' + ? null + : LocaleResolver::normalize($rawLocale); + + if ($rawLocale !== null && $rawLocale !== '' && $locale === null) { + throw ValidationException::withMessages([ + 'preferred_locale' => [__('localization.validation.unsupported_locale')], + ]); + } + + $user->forceFill(['preferred_locale' => $locale])->save(); + $user->refresh(); + + App::setLocale($resolver->resolve($request)['locale']); + + return back()->with('status', $locale === null + ? __('localization.notifications.user_preference_cleared') + : __('localization.notifications.user_preference_saved')); + } +} diff --git a/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php b/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php new file mode 100644 index 00000000..b07d372c --- /dev/null +++ b/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php @@ -0,0 +1,29 @@ +resolver->resolve($request, $plane); + + App::setLocale($context['locale']); + Carbon::setLocale($context['locale']); + + $request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context); + + return $next($request); + } +} diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php deleted file mode 100644 index ff20cc37..00000000 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php +++ /dev/null @@ -1,398 +0,0 @@ -find($this->tenantId); - - if (! $tenant instanceof Tenant) { - return; - } - - $initiator = $this->initiatorUserId !== null - ? User::query()->find($this->initiatorUserId) - : null; - - $operationRun = $operationRuns->ensureRunWithIdentity( - tenant: $tenant, - type: 'findings.lifecycle.backfill', - identityInputs: [ - 'tenant_id' => $this->tenantId, - 'trigger' => 'backfill', - ], - context: [ - 'workspace_id' => $this->workspaceId, - 'initiator_user_id' => $this->initiatorUserId, - ], - initiator: $initiator instanceof User ? $initiator : null, - ); - - $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); - - if (! $lock->get()) { - if ($operationRun->status !== OperationRunStatus::Completed->value) { - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Blocked->value, - failures: [ - [ - 'code' => 'findings.lifecycle.backfill.lock_busy', - 'message' => 'Another findings lifecycle backfill is already running for this tenant.', - ], - ], - ); - } - - $runbookService->maybeFinalize($operationRun); - - return; - } - - try { - $total = (int) Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Running->value, - outcome: OperationRunOutcome::Pending->value, - summaryCounts: [ - 'total' => $total, - 'processed' => 0, - 'updated' => 0, - 'skipped' => 0, - 'failed' => 0, - ], - ); - - $operationRun->refresh(); - - $backfillStartedAt = $operationRun->started_at !== null - ? CarbonImmutable::instance($operationRun->started_at) - : CarbonImmutable::now('UTC'); - - Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->orderBy('id') - ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void { - $processed = 0; - $updated = 0; - $skipped = 0; - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - $processed++; - - $originalAttributes = $finding->getAttributes(); - - $this->backfillLifecycleFields($finding, $backfillStartedAt); - $this->backfillLegacyAcknowledgedStatus($finding); - $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); - $this->backfillDriftRecurrenceKey($finding); - - if ($finding->isDirty()) { - $finding->save(); - $updated++; - } else { - $finding->setRawAttributes($originalAttributes, sync: true); - $skipped++; - } - } - - $operationRuns->incrementSummaryCounts($operationRun, [ - 'processed' => $processed, - 'updated' => $updated, - 'skipped' => $skipped, - ]); - }); - - $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); - - if ($consolidatedDuplicates > 0) { - $operationRuns->incrementSummaryCounts($operationRun, [ - 'updated' => $consolidatedDuplicates, - ]); - } - - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - ); - - $runbookService->maybeFinalize($operationRun); - } catch (Throwable $e) { - $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); - $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); - - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Failed->value, - failures: [[ - 'code' => 'findings.lifecycle.backfill.failed', - 'reason_code' => $reasonCode, - 'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.', - ]], - ); - - $runbookService->maybeFinalize($operationRun); - - throw $e; - } finally { - $lock->release(); - } - } - - private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void - { - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; - - if ($finding->first_seen_at === null) { - $finding->first_seen_at = $createdAt; - } - - if ($finding->last_seen_at === null) { - $finding->last_seen_at = $createdAt; - } - - if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { - $lastSeen = CarbonImmutable::instance($finding->last_seen_at); - $firstSeen = CarbonImmutable::instance($finding->first_seen_at); - - if ($lastSeen->lessThan($firstSeen)) { - $finding->last_seen_at = $firstSeen; - } - } - - $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; - - if ($timesSeen < 1) { - $finding->times_seen = 1; - } - } - - private function backfillLegacyAcknowledgedStatus(Finding $finding): void - { - if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { - return; - } - - $finding->status = Finding::STATUS_TRIAGED; - - if ($finding->triaged_at === null) { - if ($finding->acknowledged_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); - } elseif ($finding->created_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->created_at); - } - } - } - - private function backfillSlaFields( - Finding $finding, - Tenant $tenant, - FindingSlaPolicy $slaPolicy, - CarbonImmutable $backfillStartedAt, - ): void { - if (! Finding::isOpenStatus((string) $finding->status)) { - return; - } - - if ($finding->sla_days === null) { - $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); - } - - if ($finding->due_at === null) { - $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); - } - } - - private function backfillDriftRecurrenceKey(Finding $finding): void - { - if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { - return; - } - - if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { - return; - } - - $tenantId = (int) ($finding->tenant_id ?? 0); - $scopeKey = (string) ($finding->scope_key ?? ''); - $subjectType = (string) ($finding->subject_type ?? ''); - $subjectExternalId = (string) ($finding->subject_external_id ?? ''); - - if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { - return; - } - - $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; - $kind = Arr::get($evidence, 'summary.kind'); - $changeType = Arr::get($evidence, 'change_type'); - - $kind = is_string($kind) ? $kind : ''; - $changeType = is_string($changeType) ? $changeType : ''; - - if ($kind === '') { - return; - } - - $dimension = $this->recurrenceDimension($kind, $changeType); - - $finding->recurrence_key = hash('sha256', sprintf( - 'drift:%d:%s:%s:%s:%s', - $tenantId, - $scopeKey, - $subjectType, - $subjectExternalId, - $dimension, - )); - } - - private function recurrenceDimension(string $kind, string $changeType): string - { - $kind = strtolower(trim($kind)); - $changeType = strtolower(trim($changeType)); - - return match ($kind) { - 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'), - default => $kind, - }; - } - - private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int - { - $duplicateKeys = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->whereNotNull('recurrence_key') - ->select(['recurrence_key']) - ->groupBy('recurrence_key') - ->havingRaw('COUNT(*) > 1') - ->pluck('recurrence_key') - ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') - ->values(); - - if ($duplicateKeys->isEmpty()) { - return 0; - } - - $consolidated = 0; - - foreach ($duplicateKeys as $recurrenceKey) { - if (! is_string($recurrenceKey) || $recurrenceKey === '') { - continue; - } - - $findings = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('recurrence_key', $recurrenceKey) - ->orderBy('id') - ->get(); - - $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { - continue; - } - - $finding->forceFill([ - 'status' => Finding::STATUS_CLOSED, - 'resolved_at' => null, - 'resolved_reason' => null, - 'closed_at' => $backfillStartedAt, - 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, - 'closed_by_user_id' => null, - 'recurrence_key' => null, - ])->save(); - - $consolidated++; - } - } - - return $consolidated; - } - - /** - * @param Collection $findings - */ - private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding - { - if ($findings->isEmpty()) { - return null; - } - - $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); - - $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; - - $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); - - if ($alreadyCanonical instanceof Finding) { - return $alreadyCanonical; - } - - /** @var Finding $sorted */ - $sorted = $candidates - ->sortByDesc(function (Finding $finding): array { - $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; - - return [ - max($lastSeen, $createdAt), - (int) $finding->getKey(), - ]; - }) - ->first(); - - return $sorted; - } -} diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php deleted file mode 100644 index 09aae1a1..00000000 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php +++ /dev/null @@ -1,378 +0,0 @@ -find($this->tenantId); - - if (! $tenant instanceof Tenant) { - return; - } - - if ((int) $tenant->workspace_id !== $this->workspaceId) { - return; - } - - $run = OperationRun::query()->find($this->operationRunId); - - if (! $run instanceof OperationRun) { - return; - } - - if ((int) $run->workspace_id !== $this->workspaceId) { - return; - } - - if ($run->tenant_id !== null) { - return; - } - - if ($run->status === 'queued') { - $operationRunService->updateRun($run, status: 'running'); - } - - $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); - - if (! $lock->get()) { - $operationRunService->appendFailures($run, [ - [ - 'code' => 'findings.lifecycle.backfill.lock_busy', - 'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId), - ], - ]); - - $operationRunService->incrementSummaryCounts($run, [ - 'failed' => 1, - 'processed' => 1, - ]); - - $operationRunService->maybeCompleteBulkRun($run); - $runbookService->maybeFinalize($run); - - return; - } - - try { - $backfillStartedAt = $run->started_at !== null - ? CarbonImmutable::instance($run->started_at) - : CarbonImmutable::now('UTC'); - - Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->orderBy('id') - ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void { - $updated = 0; - $skipped = 0; - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - $originalAttributes = $finding->getAttributes(); - - $this->backfillLifecycleFields($finding, $backfillStartedAt); - $this->backfillLegacyAcknowledgedStatus($finding); - $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); - $this->backfillDriftRecurrenceKey($finding); - - if ($finding->isDirty()) { - $finding->save(); - $updated++; - } else { - $finding->setRawAttributes($originalAttributes, sync: true); - $skipped++; - } - } - - if ($updated > 0 || $skipped > 0) { - $operationRunService->incrementSummaryCounts($run, [ - 'updated' => $updated, - 'skipped' => $skipped, - ]); - } - }); - - $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); - - if ($consolidatedDuplicates > 0) { - $operationRunService->incrementSummaryCounts($run, [ - 'updated' => $consolidatedDuplicates, - ]); - } - - $operationRunService->incrementSummaryCounts($run, [ - 'processed' => 1, - ]); - - $operationRunService->maybeCompleteBulkRun($run); - $runbookService->maybeFinalize($run); - } catch (Throwable $e) { - $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); - $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); - - $operationRunService->appendFailures($run, [[ - 'code' => 'findings.lifecycle.backfill.failed', - 'reason_code' => $reasonCode, - 'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId), - ]]); - - $operationRunService->incrementSummaryCounts($run, [ - 'failed' => 1, - 'processed' => 1, - ]); - - $operationRunService->maybeCompleteBulkRun($run); - $runbookService->maybeFinalize($run); - - throw $e; - } finally { - $lock->release(); - } - } - - private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void - { - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; - - if ($finding->first_seen_at === null) { - $finding->first_seen_at = $createdAt; - } - - if ($finding->last_seen_at === null) { - $finding->last_seen_at = $createdAt; - } - - if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { - $lastSeen = CarbonImmutable::instance($finding->last_seen_at); - $firstSeen = CarbonImmutable::instance($finding->first_seen_at); - - if ($lastSeen->lessThan($firstSeen)) { - $finding->last_seen_at = $firstSeen; - } - } - - $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; - - if ($timesSeen < 1) { - $finding->times_seen = 1; - } - } - - private function backfillLegacyAcknowledgedStatus(Finding $finding): void - { - if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { - return; - } - - $finding->status = Finding::STATUS_TRIAGED; - - if ($finding->triaged_at === null) { - if ($finding->acknowledged_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); - } elseif ($finding->created_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->created_at); - } - } - } - - private function backfillSlaFields( - Finding $finding, - Tenant $tenant, - FindingSlaPolicy $slaPolicy, - CarbonImmutable $backfillStartedAt, - ): void { - if (! Finding::isOpenStatus((string) $finding->status)) { - return; - } - - if ($finding->sla_days === null) { - $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); - } - - if ($finding->due_at === null) { - $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); - } - } - - private function backfillDriftRecurrenceKey(Finding $finding): void - { - if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { - return; - } - - if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { - return; - } - - $tenantId = (int) ($finding->tenant_id ?? 0); - $scopeKey = (string) ($finding->scope_key ?? ''); - $subjectType = (string) ($finding->subject_type ?? ''); - $subjectExternalId = (string) ($finding->subject_external_id ?? ''); - - if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { - return; - } - - $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; - $kind = Arr::get($evidence, 'summary.kind'); - $changeType = Arr::get($evidence, 'change_type'); - - $kind = is_string($kind) ? $kind : ''; - $changeType = is_string($changeType) ? $changeType : ''; - - if ($kind === '') { - return; - } - - $dimension = $this->recurrenceDimension($kind, $changeType); - - $finding->recurrence_key = hash('sha256', sprintf( - 'drift:%d:%s:%s:%s:%s', - $tenantId, - $scopeKey, - $subjectType, - $subjectExternalId, - $dimension, - )); - } - - private function recurrenceDimension(string $kind, string $changeType): string - { - $kind = strtolower(trim($kind)); - $changeType = strtolower(trim($changeType)); - - return match ($kind) { - 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'), - default => $kind, - }; - } - - private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int - { - $duplicateKeys = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->whereNotNull('recurrence_key') - ->select(['recurrence_key']) - ->groupBy('recurrence_key') - ->havingRaw('COUNT(*) > 1') - ->pluck('recurrence_key') - ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') - ->values(); - - if ($duplicateKeys->isEmpty()) { - return 0; - } - - $consolidated = 0; - - foreach ($duplicateKeys as $recurrenceKey) { - if (! is_string($recurrenceKey) || $recurrenceKey === '') { - continue; - } - - $findings = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('recurrence_key', $recurrenceKey) - ->orderBy('id') - ->get(); - - $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { - continue; - } - - $finding->forceFill([ - 'status' => Finding::STATUS_CLOSED, - 'resolved_at' => null, - 'resolved_reason' => null, - 'closed_at' => $backfillStartedAt, - 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, - 'closed_by_user_id' => null, - 'recurrence_key' => null, - ])->save(); - - $consolidated++; - } - } - - return $consolidated; - } - - /** - * @param Collection $findings - */ - private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding - { - if ($findings->isEmpty()) { - return null; - } - - $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); - - $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; - - $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); - - if ($alreadyCanonical instanceof Finding) { - return $alreadyCanonical; - } - - /** @var Finding $sorted */ - $sorted = $candidates - ->sortByDesc(function (Finding $finding): array { - $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; - - return [ - max($lastSeen, $createdAt), - (int) $finding->getKey(), - ]; - }) - ->first(); - - return $sorted; - } -} diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php deleted file mode 100644 index f7e93a97..00000000 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php +++ /dev/null @@ -1,95 +0,0 @@ -find($this->operationRunId); - - if (! $run instanceof OperationRun) { - return; - } - - if ((int) $run->workspace_id !== $this->workspaceId) { - return; - } - - if ($run->tenant_id !== null) { - return; - } - - $tenantIds = $allowedTenantUniverse - ->query() - ->where('workspace_id', $this->workspaceId) - ->orderBy('id') - ->pluck('id') - ->map(static fn (mixed $id): int => (int) $id) - ->all(); - - $tenantCount = count($tenantIds); - - $operationRunService->updateRun( - $run, - status: OperationRunStatus::Running->value, - outcome: OperationRunOutcome::Pending->value, - summaryCounts: [ - 'tenants' => $tenantCount, - 'total' => $tenantCount, - 'processed' => 0, - 'updated' => 0, - 'skipped' => 0, - 'failed' => 0, - ], - ); - - if ($tenantCount === 0) { - $operationRunService->updateRun( - $run, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - ); - - $runbookService->maybeFinalize($run); - - return; - } - - foreach ($tenantIds as $tenantId) { - if ($tenantId <= 0) { - continue; - } - - BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch( - operationRunId: (int) $run->getKey(), - workspaceId: $this->workspaceId, - tenantId: $tenantId, - ); - } - } -} diff --git a/apps/platform/app/Models/User.php b/apps/platform/app/Models/User.php index ec5d145f..f37f13a4 100644 --- a/apps/platform/app/Models/User.php +++ b/apps/platform/app/Models/User.php @@ -39,6 +39,7 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha 'password', 'entra_tenant_id', 'entra_object_id', + 'preferred_locale', ]; /** diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 3ba79958..602bfe4f 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -7,6 +7,7 @@ use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsIntakeQueue; +use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\Monitoring\FindingExceptionsQueue; @@ -78,16 +79,16 @@ public function panel(Panel $panel): Panel ]) ->navigationItems([ WorkspaceOverview::navigationItem(), - NavigationItem::make('Integrations') + NavigationItem::make(fn (): string => __('localization.navigation.integrations')) ->url(fn (): string => route('filament.admin.resources.provider-connections.index')) ->icon('heroicon-o-link') - ->group('Settings') + ->group(fn (): string => __('localization.navigation.settings')) ->sort(15) ->visible(fn (): bool => ProviderConnectionResource::canViewAny()), - NavigationItem::make('Settings') + NavigationItem::make(fn (): string => __('localization.navigation.settings')) ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) ->icon('heroicon-o-cog-6-tooth') - ->group('Settings') + ->group(fn (): string => __('localization.navigation.settings')) ->sort(20) ->visible(function (): bool { $user = auth()->user(); @@ -114,12 +115,12 @@ public function panel(Panel $panel): Panel return $resolver->isMember($user, $workspace) && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); }), - NavigationItem::make('Manage workspaces') + NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces')) ->url(function (): string { return route('filament.admin.resources.workspaces.index'); }) ->icon('heroicon-o-squares-2x2') - ->group('Settings') + ->group(fn (): string => __('localization.navigation.settings')) ->sort(10) ->visible(function (): bool { $user = auth()->user(); @@ -135,15 +136,15 @@ public function panel(Panel $panel): Panel ->whereIn('role', $roles) ->exists(); }), - NavigationItem::make('Operations') + NavigationItem::make(fn (): string => __('localization.navigation.operations')) ->url(fn (): string => route('admin.operations.index')) ->icon('heroicon-o-queue-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(10), - NavigationItem::make('Audit Log') + NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) ->url(fn (): string => route('admin.monitoring.audit-log')) ->icon('heroicon-o-clipboard-document-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(30), ]) ->renderHook( @@ -180,6 +181,7 @@ public function panel(Panel $panel): Panel InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, + GovernanceInbox::class, FindingsHygieneReport::class, FindingsIntakeQueue::class, MyFindingsInbox::class, @@ -208,6 +210,7 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->middleware(['apply-resolved-locale:admin'], isPersistent: true) ->authMiddleware([ Authenticate::class, ]); diff --git a/apps/platform/app/Providers/Filament/SystemPanelProvider.php b/apps/platform/app/Providers/Filament/SystemPanelProvider.php index a217a9a1..864f6524 100644 --- a/apps/platform/app/Providers/Filament/SystemPanelProvider.php +++ b/apps/platform/app/Providers/Filament/SystemPanelProvider.php @@ -42,6 +42,14 @@ public function panel(Panel $panel): Panel PanelsRenderHook::BODY_START, fn () => view('filament.system.components.break-glass-banner')->render(), ) + ->renderHook( + PanelsRenderHook::TOPBAR_START, + fn () => view('filament.partials.locale-switcher', [ + 'plane' => 'system', + 'showPreference' => false, + 'embedded' => false, + ])->render(), + ) ->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages') ->pages([ Dashboard::class, @@ -59,6 +67,7 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->middleware(['apply-resolved-locale:system'], isPersistent: true) ->authMiddleware([ Authenticate::class, 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, diff --git a/apps/platform/app/Providers/Filament/TenantPanelProvider.php b/apps/platform/app/Providers/Filament/TenantPanelProvider.php index 5966ec2e..4db2a8b2 100644 --- a/apps/platform/app/Providers/Filament/TenantPanelProvider.php +++ b/apps/platform/app/Providers/Filament/TenantPanelProvider.php @@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel 'primary' => Color::Indigo, ]) ->navigationItems([ - NavigationItem::make(OperationRunLinks::collectionLabel()) + NavigationItem::make(fn (): string => __('localization.navigation.operations')) ->url(fn (): string => route('admin.operations.index')) ->icon('heroicon-o-queue-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(10), - NavigationItem::make('Alerts') + NavigationItem::make(fn (): string => __('localization.navigation.alerts')) ->url(fn (): string => url('/admin/alerts')) ->icon('heroicon-o-bell-alert') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(20), - NavigationItem::make('Audit Log') + NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) ->url(fn (): string => route('admin.monitoring.audit-log')) ->icon('heroicon-o-clipboard-document-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(30), ]) ->renderHook( @@ -111,6 +111,7 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->middleware(['apply-resolved-locale:tenant'], isPersistent: true) ->authMiddleware([ Authenticate::class, ]); diff --git a/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php b/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php new file mode 100644 index 00000000..cf47cd8c --- /dev/null +++ b/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php @@ -0,0 +1,410 @@ + + */ + public static function stateIds(): array + { + return [ + self::STATE_TRIAL, + self::STATE_GRACE, + self::STATE_ACTIVE_PAID, + self::STATE_SUSPENDED_READ_ONLY, + ]; + } + + /** + * @return array + */ + public static function stateLabels(): array + { + return [ + self::STATE_TRIAL => 'Trial', + self::STATE_GRACE => 'Grace', + self::STATE_ACTIVE_PAID => 'Active paid', + self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only', + ]; + } + + /** + * @return array + */ + public static function stateDescriptions(): array + { + return [ + self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.', + self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.', + self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.', + self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.', + ]; + } + + /** + * @return array + */ + public function summary(Workspace $workspace): array + { + $lifecycle = $this->resolve($workspace); + + return $lifecycle + [ + 'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace), + 'action_decisions' => [ + self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle), + self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle), + self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle), + self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle), + self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle), + ], + ]; + } + + /** + * @return array{ + * workspace_id: int, + * state: string, + * state_label: string, + * source: string, + * source_label: string, + * rationale: string|null, + * description: string, + * last_changed_at: CarbonInterface|null, + * last_changed_by: string|null + * } + */ + public function resolve(Workspace $workspace): array + { + $stateSetting = $this->settingsResolver->resolveDetailed( + workspace: $workspace, + domain: self::SETTING_DOMAIN, + key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE, + ); + + $rawState = is_string($stateSetting['value'] ?? null) + ? strtolower(trim((string) $stateSetting['value'])) + : null; + + $state = in_array($rawState, self::stateIds(), true) + ? $rawState + : self::STATE_ACTIVE_PAID; + + $source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null + ? self::SOURCE_WORKSPACE_SETTING + : self::SOURCE_DEFAULT_ACTIVE_PAID; + + $rationale = $this->settingsResolver->resolveValue( + workspace: $workspace, + domain: self::SETTING_DOMAIN, + key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON, + ); + + $labels = self::stateLabels(); + $descriptions = self::stateDescriptions(); + $lastChanged = $this->lastChangedMetadata($workspace); + + return [ + 'workspace_id' => (int) $workspace->getKey(), + 'state' => $state, + 'state_label' => $labels[$state], + 'source' => $source, + 'source_label' => $source === self::SOURCE_WORKSPACE_SETTING + ? 'workspace setting' + : 'default active paid', + 'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null, + 'description' => $descriptions[$state], + 'last_changed_at' => $lastChanged['last_changed_at'], + 'last_changed_by' => $lastChanged['last_changed_by'], + ]; + } + + /** + * @param array|null $lifecycle + * @return array + */ + public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array + { + $lifecycle ??= $this->resolve($workspace); + + return match ($actionKey) { + self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle), + self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle), + self::ACTION_REVIEW_HISTORY_READ, + self::ACTION_EVIDENCE_READ, + self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle), + default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)), + }; + } + + /** + * @return array + */ + public function reviewPackStartDecisionForTenant(Tenant $tenant): array + { + $tenant->loadMissing('workspace'); + + return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START); + } + + /** + * @param array $lifecycle + * @return array + */ + private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array + { + $substrateDecision = $this->workspaceEntitlementResolver->resolve( + $workspace, + WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, + ); + + if ((bool) ($substrateDecision['is_blocked'] ?? false)) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'), + substrateDecision: $substrateDecision, + ); + } + + return match ($lifecycle['state']) { + self::STATE_GRACE => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'New managed-tenant activation is frozen while this workspace is in grace.', + substrateDecision: $substrateDecision, + ), + self::STATE_SUSPENDED_READ_ONLY => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.', + substrateDecision: $substrateDecision, + ), + default => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_ALLOW, + reasonFamily: null, + message: 'Managed-tenant activation is available for this workspace commercial state.', + substrateDecision: $substrateDecision, + ), + }; + } + + /** + * @param array $lifecycle + * @return array + */ + private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array + { + $substrateDecision = $this->workspaceEntitlementResolver->resolve( + $workspace, + WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, + ); + + if ((bool) ($substrateDecision['is_blocked'] ?? false)) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'), + substrateDecision: $substrateDecision, + ); + } + + return match ($lifecycle['state']) { + self::STATE_GRACE => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_WARN, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.', + substrateDecision: $substrateDecision, + ), + self::STATE_SUSPENDED_READ_ONLY => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.', + substrateDecision: $substrateDecision, + ), + default => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_ALLOW, + reasonFamily: null, + message: 'Review-pack starts are available for this workspace commercial state.', + substrateDecision: $substrateDecision, + ), + }; + } + + /** + * @param array $lifecycle + * @return array + */ + private function readOnlyDecision(string $actionKey, array $lifecycle): array + { + if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: $actionKey, + outcome: self::OUTCOME_ALLOW_READ_ONLY, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.', + substrateDecision: null, + ); + } + + return $this->decision( + lifecycle: $lifecycle, + actionKey: $actionKey, + outcome: self::OUTCOME_ALLOW, + reasonFamily: null, + message: 'Read-only history remains available under current RBAC.', + substrateDecision: null, + ); + } + + /** + * @param array $lifecycle + * @param array|null $substrateDecision + * @return array + */ + private function decision( + array $lifecycle, + string $actionKey, + string $outcome, + ?string $reasonFamily, + string $message, + ?array $substrateDecision, + ): array { + return [ + 'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0), + 'action_key' => $actionKey, + 'outcome' => $outcome, + 'is_blocked' => $outcome === self::OUTCOME_BLOCK, + 'is_warning' => $outcome === self::OUTCOME_WARN, + 'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null, + 'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null, + 'message' => $message, + 'reason_family' => $reasonFamily, + 'state' => (string) $lifecycle['state'], + 'state_label' => (string) $lifecycle['state_label'], + 'source' => (string) $lifecycle['source'], + 'source_label' => (string) $lifecycle['source_label'], + 'rationale' => $lifecycle['rationale'] ?? null, + 'entitlement_decision' => $substrateDecision, + ]; + } + + /** + * @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} + */ + private function lastChangedMetadata(Workspace $workspace): array + { + $audit = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceSettingUpdated->value) + ->where('resource_type', 'workspace_setting') + ->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE) + ->latest('recorded_at') + ->latest('id') + ->first(); + + if ($audit instanceof AuditLog) { + return [ + 'last_changed_at' => $audit->recorded_at, + 'last_changed_by' => $audit->actorDisplayLabel(), + ]; + } + + $record = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', self::SETTING_DOMAIN) + ->whereIn('key', [ + self::SETTING_COMMERCIAL_LIFECYCLE_STATE, + self::SETTING_COMMERCIAL_LIFECYCLE_REASON, + ]) + ->with('updatedByUser:id,name') + ->latest('updated_at') + ->latest('id') + ->first(); + + if (! $record instanceof WorkspaceSetting) { + return [ + 'last_changed_at' => null, + 'last_changed_by' => null, + ]; + } + + return [ + 'last_changed_at' => $record->updated_at, + 'last_changed_by' => $record->updatedByUser?->name, + ]; + } +} diff --git a/apps/platform/app/Services/Localization/LocaleResolver.php b/apps/platform/app/Services/Localization/LocaleResolver.php new file mode 100644 index 00000000..f3ce65e5 --- /dev/null +++ b/apps/platform/app/Services/Localization/LocaleResolver.php @@ -0,0 +1,215 @@ + + */ + private const SUPPORTED_LOCALES = ['en', 'de']; + + public function __construct( + private SettingsResolver $settingsResolver, + private WorkspaceContext $workspaceContext, + ) {} + + /** + * @return list + */ + public static function supportedLocales(): array + { + return self::SUPPORTED_LOCALES; + } + + /** + * @return array + */ + public static function localeOptions(): array + { + return [ + 'en' => __('localization.locales.en'), + 'de' => __('localization.locales.de'), + ]; + } + + public static function isSupported(mixed $locale): bool + { + return self::normalize($locale) !== null; + } + + public static function normalize(mixed $locale): ?string + { + if (! is_string($locale)) { + return null; + } + + $normalized = strtolower(trim($locale)); + + return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null; + } + + /** + * @return array{ + * locale: string, + * source: string, + * fallback_locale: string, + * user_preference_locale: ?string, + * workspace_default_locale: ?string, + * machine_artifacts_invariant: true + * } + */ + public function resolve(Request $request, ?string $plane = null): array + { + $plane = $this->normalizePlane($plane, $request); + + $explicitOverride = $this->explicitOverride($request); + $systemDefault = (string) config('app.fallback_locale', 'en'); + + if ($plane === 'system') { + return $this->resolveFromSources( + explicitOverride: $explicitOverride, + userPreference: null, + workspaceDefault: null, + systemDefault: $systemDefault, + includeUserPreference: false, + includeWorkspaceDefault: false, + ); + } + + $user = $request->user(); + $userPreference = $user instanceof User ? $user->preferred_locale : null; + $workspaceDefault = $this->workspaceDefault($request); + + return $this->resolveFromSources( + explicitOverride: $explicitOverride, + userPreference: $userPreference, + workspaceDefault: $workspaceDefault, + systemDefault: $systemDefault, + includeUserPreference: true, + includeWorkspaceDefault: true, + ); + } + + /** + * @return array{ + * locale: string, + * source: string, + * fallback_locale: string, + * user_preference_locale: ?string, + * workspace_default_locale: ?string, + * machine_artifacts_invariant: true + * } + */ + public function resolveFromSources( + mixed $explicitOverride, + mixed $userPreference, + mixed $workspaceDefault, + mixed $systemDefault, + bool $includeUserPreference = true, + bool $includeWorkspaceDefault = true, + ): array { + $fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en'; + + $candidates = [ + self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride), + ]; + + if ($includeUserPreference) { + $candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference); + } + + if ($includeWorkspaceDefault) { + $candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault); + } + + $candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale; + + foreach ($candidates as $source => $locale) { + if ($locale !== null) { + return [ + 'locale' => $locale, + 'source' => $source, + 'fallback_locale' => $fallbackLocale, + 'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null, + 'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null, + 'machine_artifacts_invariant' => true, + ]; + } + } + + return [ + 'locale' => $fallbackLocale, + 'source' => self::SOURCE_SYSTEM_DEFAULT, + 'fallback_locale' => $fallbackLocale, + 'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null, + 'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null, + 'machine_artifacts_invariant' => true, + ]; + } + + private function explicitOverride(Request $request): ?string + { + $queryLocale = self::normalize($request->query('locale')); + + if ($queryLocale !== null) { + return $queryLocale; + } + + if (! $request->hasSession()) { + return null; + } + + return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY)); + } + + private function workspaceDefault(Request $request): ?string + { + $workspace = $this->workspaceContext->currentWorkspace($request); + + if (! $workspace instanceof Workspace) { + return null; + } + + return self::normalize($this->settingsResolver->resolveValue( + workspace: $workspace, + domain: self::SETTING_DOMAIN, + key: self::SETTING_DEFAULT_LOCALE, + )); + } + + private function normalizePlane(?string $plane, Request $request): string + { + $plane = strtolower(trim((string) $plane)); + + if (in_array($plane, ['admin', 'tenant', 'system'], true)) { + return $plane; + } + + return $request->is('system', 'system/*') ? 'system' : 'admin'; + } +} diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index 2ca0e04b..69fb9357 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -14,7 +14,7 @@ use App\Models\TenantReview; use App\Models\User; use App\Services\Audit\WorkspaceAuditLogger; -use App\Services\Entitlements\WorkspaceEntitlementResolver; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Evidence\EvidenceResolutionRequest; use App\Services\Evidence\EvidenceSnapshotResolver; use App\Support\Audit\AuditActionId; @@ -30,7 +30,7 @@ public function __construct( private OperationRunService $operationRunService, private EvidenceSnapshotResolver $snapshotResolver, private WorkspaceAuditLogger $auditLogger, - private WorkspaceEntitlementResolver $workspaceEntitlementResolver, + private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver, private ProductTelemetryRecorder $productTelemetryRecorder, ) {} @@ -253,10 +253,22 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s */ public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array { - return $this->workspaceEntitlementResolver->resolve( + $tenant->loadMissing('workspace'); + $decision = $this->workspaceCommercialLifecycleResolver->actionDecision( $tenant->workspace, - WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, + WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START, ); + + $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) + ? $decision['entitlement_decision'] + : []; + + return $decision + [ + 'effective_value' => $entitlementDecision['effective_value'] ?? null, + 'source' => $decision['source'] ?? null, + 'current_usage' => $entitlementDecision['current_usage'] ?? null, + 'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null, + ]; } private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void diff --git a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php deleted file mode 100644 index 8302ff85..00000000 --- a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php +++ /dev/null @@ -1,739 +0,0 @@ -computePreflight($scope); - - $this->auditSafely( - action: 'platform.ops.runbooks.preflight', - scope: $scope, - operationRunId: null, - initiator: null, - context: [ - 'preflight' => $result, - ], - ); - - return $result; - } - - public function start( - FindingsLifecycleBackfillScope $scope, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - string $source, - ): OperationRun { - $source = trim($source); - - if ($source === '') { - throw ValidationException::withMessages([ - 'source' => 'A run source is required.', - ]); - } - - $isBreakGlassActive = $this->breakGlassSession->isActive(); - - if ($scope->isAllTenants() || $isBreakGlassActive) { - if (! $reason instanceof RunbookReason) { - throw ValidationException::withMessages([ - 'reason' => 'A reason is required for this run.', - ]); - } - } - - $preflight = $this->computePreflight($scope); - - if (($preflight['affected_count'] ?? 0) <= 0) { - throw ValidationException::withMessages([ - 'preflight.affected_count' => 'Nothing to do for this scope.', - ]); - } - - $workspace = null; - $tenant = null; - - if ($scope->isSingleTenant()) { - $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail(); - $this->allowedTenantUniverse->ensureAllowed($tenant); - - $workspace = $tenant->workspace; - } else { - $platformTenant = $this->platformTenant(); - $workspace = $platformTenant->workspace; - } - - if (! $workspace instanceof Workspace) { - throw new \RuntimeException('Platform tenant is missing its workspace.'); - } - - $decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace); - - if ($decision->isPaused()) { - $this->auditBlockedStart( - decision: $decision, - scope: $scope, - workspace: $workspace, - tenant: $tenant, - initiator: $initiator, - source: $source, - ); - - throw OperationalControlBlockedException::forDecision( - decision: $decision, - actionLabel: OperationCatalog::label(self::RUNBOOK_KEY), - ); - } - - if ($scope->isAllTenants()) { - $lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey()); - $lock = Cache::lock($lockKey, 900); - - if (! $lock->get()) { - throw ValidationException::withMessages([ - 'scope' => 'Another run is already in progress for this scope.', - ]); - } - - try { - return $this->startAllTenants( - workspace: $workspace, - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ); - } finally { - $lock->release(); - } - } - - return $this->startSingleTenant( - tenant: $tenant, - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ); - } - - public function maybeFinalize(OperationRun $run): void - { - $run->refresh(); - - if ($run->status !== OperationRunStatus::Completed->value) { - return; - } - - $context = is_array($run->context) ? $run->context : []; - - if ((string) data_get($context, 'runbook.key') !== self::RUNBOOK_KEY) { - return; - } - - $lockKey = sprintf('tenantpilot:runbooks:%s:finalize:%d', self::RUNBOOK_KEY, (int) $run->getKey()); - $lock = Cache::lock($lockKey, 86400); - - if (! $lock->get()) { - return; - } - - try { - $this->auditSafely( - action: $run->outcome === OperationRunOutcome::Failed->value - ? 'platform.ops.runbooks.failed' - : 'platform.ops.runbooks.completed', - scope: $this->scopeFromRunContext($context), - operationRunId: (int) $run->getKey(), - context: [ - 'status' => (string) $run->status, - 'outcome' => (string) $run->outcome, - 'is_break_glass' => (bool) data_get($context, 'platform_initiator.is_break_glass', false), - 'reason_code' => data_get($context, 'reason.reason_code'), - 'reason_text' => data_get($context, 'reason.reason_text'), - ], - ); - - $this->notifyInitiatorSafely($run); - - if ($run->outcome === OperationRunOutcome::Failed->value) { - $this->dispatchFailureAlertSafely($run); - } - } finally { - $lock->release(); - } - } - - /** - * @return array{affected_count: int, total_count: int, estimated_tenants?: int|null} - */ - private function computePreflight(FindingsLifecycleBackfillScope $scope): array - { - if ($scope->isSingleTenant()) { - $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail(); - $this->allowedTenantUniverse->ensureAllowed($tenant); - - return $this->computeTenantPreflight($tenant); - } - - $platformTenant = $this->platformTenant(); - $workspaceId = (int) ($platformTenant->workspace_id ?? 0); - - $tenants = $this->allowedTenantUniverse - ->query() - ->where('workspace_id', $workspaceId) - ->orderBy('id') - ->get(); - - $affected = 0; - $total = 0; - - foreach ($tenants as $tenant) { - if (! $tenant instanceof Tenant) { - continue; - } - - $counts = $this->computeTenantPreflight($tenant); - - $affected += (int) ($counts['affected_count'] ?? 0); - $total += (int) ($counts['total_count'] ?? 0); - } - - return [ - 'affected_count' => $affected, - 'total_count' => $total, - 'estimated_tenants' => $tenants->count(), - ]; - } - - /** - * @return array{affected_count: int, total_count: int} - */ - private function computeTenantPreflight(Tenant $tenant): array - { - $query = Finding::query()->where('tenant_id', (int) $tenant->getKey()); - - $total = (int) (clone $query)->count(); - - $affected = 0; - - (clone $query) - ->orderBy('id') - ->chunkById(500, function ($findings) use (&$affected): void { - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - if ($this->findingNeedsBackfill($finding)) { - $affected++; - } - } - }); - - $affected += $this->countDriftDuplicateConsolidations($tenant); - - return [ - 'affected_count' => $affected, - 'total_count' => $total, - ]; - } - - private function findingNeedsBackfill(Finding $finding): bool - { - if ($finding->first_seen_at === null || $finding->last_seen_at === null) { - return true; - } - - if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { - if ($finding->last_seen_at->lt($finding->first_seen_at)) { - return true; - } - } - - $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; - - if ($timesSeen < 1) { - return true; - } - - if ($finding->status === Finding::STATUS_ACKNOWLEDGED) { - return true; - } - - if (Finding::isOpenStatus((string) $finding->status)) { - if ($finding->sla_days === null || $finding->due_at === null) { - return true; - } - } - - if ($finding->finding_type === Finding::FINDING_TYPE_DRIFT) { - $recurrenceKey = $finding->recurrence_key !== null ? trim((string) $finding->recurrence_key) : ''; - - if ($recurrenceKey === '') { - $scopeKey = trim((string) ($finding->scope_key ?? '')); - $subjectType = trim((string) ($finding->subject_type ?? '')); - $subjectExternalId = trim((string) ($finding->subject_external_id ?? '')); - - if ($scopeKey !== '' && $subjectType !== '' && $subjectExternalId !== '') { - $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; - $kind = data_get($evidence, 'summary.kind'); - - if (is_string($kind) && trim($kind) !== '') { - return true; - } - } - } - } - - return false; - } - - private function countDriftDuplicateConsolidations(Tenant $tenant): int - { - $rows = Finding::query() - ->where('tenant_id', (int) $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->whereNotNull('recurrence_key') - ->select(['recurrence_key', DB::raw('COUNT(*) as count')]) - ->groupBy('recurrence_key') - ->havingRaw('COUNT(*) > 1') - ->get(); - - $duplicates = 0; - - foreach ($rows as $row) { - $count = is_numeric($row->count ?? null) ? (int) $row->count : 0; - - if ($count > 1) { - $duplicates += ($count - 1); - } - } - - return $duplicates; - } - - private function startAllTenants( - Workspace $workspace, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - array $preflight, - string $source, - bool $isBreakGlassActive, - ): OperationRun { - $run = $this->operationRunService->ensureWorkspaceRunWithIdentity( - workspace: $workspace, - type: self::RUNBOOK_KEY, - identityInputs: [ - 'runbook' => self::RUNBOOK_KEY, - 'scope' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ], - context: $this->buildRunContext( - workspaceId: (int) $workspace->getKey(), - scope: FindingsLifecycleBackfillScope::allTenants(), - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ), - initiator: $initiator instanceof User ? $initiator : null, - ); - - if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { - $run->update(['initiator_name' => $initiator->name ?: $initiator->email]); - $run->refresh(); - } - - $this->auditSafely( - action: 'platform.ops.runbooks.start', - scope: FindingsLifecycleBackfillScope::allTenants(), - operationRunId: (int) $run->getKey(), - initiator: $initiator, - context: [ - 'preflight' => $preflight, - 'is_break_glass' => $isBreakGlassActive, - ] + ($reason instanceof RunbookReason ? $reason->toArray() : []), - ); - - if (! $run->wasRecentlyCreated) { - return $run; - } - - $this->operationRunService->dispatchOrFail($run, function () use ($run, $workspace): void { - BackfillFindingLifecycleWorkspaceJob::dispatch( - operationRunId: (int) $run->getKey(), - workspaceId: (int) $workspace->getKey(), - ); - }); - - return $run; - } - - private function startSingleTenant( - ?Tenant $tenant, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - array $preflight, - string $source, - bool $isBreakGlassActive, - ): OperationRun { - if (! $tenant instanceof Tenant) { - throw new \RuntimeException('Target tenant is required for single-tenant runs.'); - } - - $run = $this->operationRunService->ensureRunWithIdentity( - tenant: $tenant, - type: self::RUNBOOK_KEY, - identityInputs: [ - 'tenant_id' => (int) $tenant->getKey(), - 'trigger' => 'backfill', - ], - context: $this->buildRunContext( - workspaceId: (int) $tenant->workspace_id, - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ), - initiator: $initiator instanceof User ? $initiator : null, - ); - - if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { - $run->update(['initiator_name' => $initiator->name ?: $initiator->email]); - $run->refresh(); - } - - $this->auditSafely( - action: 'platform.ops.runbooks.start', - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - operationRunId: (int) $run->getKey(), - initiator: $initiator, - context: [ - 'preflight' => $preflight, - 'is_break_glass' => $isBreakGlassActive, - ] + ($reason instanceof RunbookReason ? $reason->toArray() : []), - ); - - if (! $run->wasRecentlyCreated) { - return $run; - } - - $this->operationRunService->dispatchOrFail($run, function () use ($tenant): void { - BackfillFindingLifecycleJob::dispatch( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: null, - ); - }); - - return $run; - } - - private function platformTenant(): Tenant - { - $tenant = Tenant::query()->where('external_id', 'platform')->first(); - - if (! $tenant instanceof Tenant) { - throw new \RuntimeException('Platform tenant is missing.'); - } - - return $tenant; - } - - /** - * @return array - */ - private function buildRunContext( - int $workspaceId, - FindingsLifecycleBackfillScope $scope, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - array $preflight, - string $source, - bool $isBreakGlassActive, - ): array { - $context = [ - 'workspace_id' => $workspaceId, - 'runbook' => [ - 'key' => self::RUNBOOK_KEY, - 'scope' => $scope->mode, - 'target_tenant_id' => $scope->tenantId, - 'source' => $source, - ], - 'preflight' => [ - 'affected_count' => (int) ($preflight['affected_count'] ?? 0), - 'total_count' => (int) ($preflight['total_count'] ?? 0), - 'estimated_tenants' => $preflight['estimated_tenants'] ?? null, - ], - ]; - - if ($reason instanceof RunbookReason) { - $context['reason'] = $reason->toArray(); - } - - if ($initiator instanceof PlatformUser) { - $context['platform_initiator'] = [ - 'platform_user_id' => (int) $initiator->getKey(), - 'email' => (string) $initiator->email, - 'name' => (string) $initiator->name, - 'is_break_glass' => $isBreakGlassActive, - ]; - } elseif ($initiator instanceof User) { - $context['tenant_initiator'] = [ - 'user_id' => (int) $initiator->getKey(), - 'email' => (string) $initiator->email, - 'name' => (string) $initiator->name, - ]; - } - - return $context; - } - - private function scopeFromRunContext(array $context): FindingsLifecycleBackfillScope - { - $scope = data_get($context, 'runbook.scope'); - $tenantId = data_get($context, 'runbook.target_tenant_id'); - - if ($scope === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT && is_numeric($tenantId)) { - return FindingsLifecycleBackfillScope::singleTenant((int) $tenantId); - } - - return FindingsLifecycleBackfillScope::allTenants(); - } - - /** - * @param array $context - */ - private function auditSafely( - string $action, - FindingsLifecycleBackfillScope $scope, - ?int $operationRunId, - User|PlatformUser|null $initiator, - array $context = [], - ): void { - try { - $metadata = [ - 'runbook_key' => self::RUNBOOK_KEY, - 'scope' => $scope->mode, - 'target_tenant_id' => $scope->tenantId, - 'operation_run_id' => $operationRunId, - 'ip' => request()->ip(), - 'user_agent' => request()->userAgent(), - ]; - - if ($initiator instanceof User && $scope->isSingleTenant()) { - $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first(); - - if ($tenant instanceof Tenant) { - $this->auditLogger->log( - tenant: $tenant, - action: $action, - context: [ - 'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null), - ] + $context, - actorId: (int) $initiator->getKey(), - actorEmail: (string) $initiator->email, - actorName: (string) $initiator->name, - status: 'success', - resourceType: 'operation_run', - resourceId: $operationRunId !== null ? (string) $operationRunId : null, - ); - - return; - } - } - - $platformTenant = $this->platformTenant(); - $platformActor = $initiator instanceof PlatformUser - ? $initiator - : auth('platform')->user(); - - $actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null; - $actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null; - $actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null; - - $this->auditLogger->log( - tenant: $platformTenant, - action: $action, - context: [ - 'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null), - ] + $context, - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - status: 'success', - resourceType: 'operation_run', - resourceId: $operationRunId !== null ? (string) $operationRunId : null, - ); - } catch (Throwable) { - // Audit is fail-safe (must not crash runbooks). - } - } - - private function auditBlockedStart( - \App\Support\OperationalControls\OperationalControlDecision $decision, - FindingsLifecycleBackfillScope $scope, - Workspace $workspace, - ?Tenant $tenant, - User|PlatformUser|null $initiator, - string $source, - ): void { - try { - $metadata = array_filter([ - 'control_key' => $decision->controlKey, - 'scope_type' => $decision->matchedScopeType, - 'workspace_id' => (int) $workspace->getKey(), - 'reason_text' => $decision->reasonText, - 'expires_at' => $decision->expiresAt?->toIso8601String(), - 'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null, - 'requested_scope' => $scope->mode, - 'target_tenant_id' => $scope->tenantId, - 'source' => $source, - 'runbook_key' => self::RUNBOOK_KEY, - ], static fn (mixed $value): bool => $value !== null && $value !== ''); - - $summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY)); - - if ($scope->isAllTenants()) { - $this->auditRecorder->record( - action: AuditActionId::OperationalControlExecutionBlocked, - context: ['metadata' => $metadata], - actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null, - target: new AuditTargetSnapshot( - type: 'operational_control', - id: $decision->sourceActivationId, - label: OperationCatalog::label(self::RUNBOOK_KEY), - ), - outcome: 'blocked', - summary: $summary, - ); - - return; - } - - if (! $tenant instanceof Tenant) { - return; - } - - $this->workspaceAuditLogger->log( - workspace: $workspace, - action: AuditActionId::OperationalControlExecutionBlocked, - context: ['metadata' => $metadata], - actor: $initiator, - status: 'blocked', - resourceType: 'operational_control', - resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null, - targetLabel: OperationCatalog::label(self::RUNBOOK_KEY), - summary: $summary, - tenant: $tenant, - ); - } catch (Throwable) { - // Audit is fail-safe (must not crash runbooks). - } - } - - private function notifyInitiatorSafely(OperationRun $run): void - { - try { - $platformUserId = data_get($run->context, 'platform_initiator.platform_user_id'); - - if (! is_numeric($platformUserId)) { - return; - } - - $platformUser = PlatformUser::query()->whereKey((int) $platformUserId)->first(); - - if (! $platformUser instanceof PlatformUser) { - return; - } - - $platformUser->notify(new OperationRunCompleted($run)); - } catch (Throwable) { - // Notifications must not crash the runbook. - } - } - - private function dispatchFailureAlertSafely(OperationRun $run): void - { - try { - $platformTenant = $this->platformTenant(); - $workspace = $platformTenant->workspace; - - if (! $workspace instanceof Workspace) { - return; - } - - $this->alertDispatchService->dispatchEvent($workspace, [ - 'tenant_id' => (int) $platformTenant->getKey(), - 'event_type' => 'operations.run.failed', - 'severity' => 'high', - 'title' => 'Operation failed: Findings lifecycle backfill', - 'body' => 'A findings lifecycle backfill run failed.', - 'metadata' => [ - 'operation_run_id' => (int) $run->getKey(), - 'operation_type' => $run->canonicalOperationType(), - 'scope' => (string) data_get($run->context, 'runbook.scope', ''), - 'view_run_url' => SystemOperationRunLinks::view($run), - ], - ]); - } catch (Throwable) { - // Alerts must not crash the runbook. - } - } -} diff --git a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php deleted file mode 100644 index 3c913f70..00000000 --- a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php +++ /dev/null @@ -1,81 +0,0 @@ - 'Select a valid tenant.', - ]); - } - - return new self( - mode: self::MODE_SINGLE_TENANT, - tenantId: $tenantId, - ); - } - - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - $mode = trim((string) ($data['mode'] ?? '')); - - if ($mode === '' || $mode === self::MODE_ALL_TENANTS) { - return self::allTenants(); - } - - if ($mode !== self::MODE_SINGLE_TENANT) { - throw ValidationException::withMessages([ - 'scope.mode' => 'Select a valid scope mode.', - ]); - } - - $tenantId = $data['tenant_id'] ?? null; - - if (! is_numeric($tenantId)) { - throw ValidationException::withMessages([ - 'scope.tenant_id' => 'Select a tenant.', - ]); - } - - return self::singleTenant((int) $tenantId); - } - - public function isAllTenants(): bool - { - return $this->mode === self::MODE_ALL_TENANTS; - } - - public function isSingleTenant(): bool - { - return $this->mode === self::MODE_SINGLE_TENANT; - } -} diff --git a/apps/platform/app/Services/Settings/SettingsWriter.php b/apps/platform/app/Services/Settings/SettingsWriter.php index f37a35d6..ca2b1e30 100644 --- a/apps/platform/app/Services/Settings/SettingsWriter.php +++ b/apps/platform/app/Services/Settings/SettingsWriter.php @@ -4,6 +4,7 @@ namespace App\Services\Settings; +use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\TenantSetting; use App\Models\User; @@ -11,11 +12,14 @@ use App\Models\WorkspaceSetting; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; +use App\Support\Auth\PlatformCapabilities; use App\Support\Settings\SettingDefinition; use App\Support\Settings\SettingsRegistry; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -33,27 +37,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string { $this->authorizeManage($actor, $workspace); - $definition = $this->requireDefinition($domain, $key); - $normalizedValue = $this->validatedValue($definition, $value); - - $existing = WorkspaceSetting::query() - ->where('workspace_id', (int) $workspace->getKey()) - ->where('domain', $domain) - ->where('key', $key) - ->first(); - - $beforeValue = $existing instanceof WorkspaceSetting - ? $this->decodeStoredValue($existing->getAttribute('value')) - : null; - - $setting = WorkspaceSetting::query()->updateOrCreate([ - 'workspace_id' => (int) $workspace->getKey(), - 'domain' => $domain, - 'key' => $key, - ], [ - 'value' => $normalizedValue, - 'updated_by_user_id' => (int) $actor->getKey(), - ]); + $result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey()); $this->resolver->clearCache(); @@ -67,7 +51,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string 'scope' => 'workspace', 'domain' => $domain, 'key' => $key, - 'before_value' => $beforeValue, + 'before_value' => $result['before_value'], 'after_value' => $afterValue, ], ], @@ -76,7 +60,79 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string resourceId: $domain.'.'.$key, ); - return $setting; + return $result['setting']; + } + + public function updateWorkspaceCommercialLifecycle( + PlatformUser $actor, + Workspace $workspace, + string $state, + string $reason, + ): void { + $state = strtolower(trim($state)); + $reason = trim($reason); + + if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) { + throw new AuthorizationException('Missing commercial lifecycle manage capability.'); + } + + if ($reason === '') { + throw ValidationException::withMessages([ + 'reason' => ['A rationale is required when changing commercial lifecycle state.'], + ]); + } + + DB::transaction(function () use ($actor, $workspace, $state, $reason): void { + $stateResult = $this->persistWorkspaceSetting( + workspace: $workspace, + domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + value: $state, + updatedByUserId: null, + ); + + $reasonResult = $this->persistWorkspaceSetting( + workspace: $workspace, + domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, + value: $reason, + updatedByUserId: null, + ); + + $this->resolver->clearCache(); + + $afterState = $this->resolver->resolveValue( + $workspace, + WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + ); + + $afterReason = $this->resolver->resolveValue( + $workspace, + WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, + ); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSettingUpdated->value, + context: [ + 'metadata' => [ + 'scope' => 'workspace', + 'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + 'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + 'before_state' => $stateResult['before_value'], + 'after_state' => $afterState, + 'before_reason' => $reasonResult['before_value'], + 'after_reason' => $afterReason, + ], + ], + actor: $actor, + resourceType: 'workspace_setting', + resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + targetLabel: 'Commercial lifecycle state', + ); + }); } public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void @@ -174,6 +230,39 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti ]); } + /** + * @return array{setting: WorkspaceSetting, before_value: mixed} + */ + private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array + { + $definition = $this->requireDefinition($domain, $key); + $normalizedValue = $this->validatedValue($definition, $value); + + $existing = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(); + + $beforeValue = $existing instanceof WorkspaceSetting + ? $this->decodeStoredValue($existing->getAttribute('value')) + : null; + + $setting = WorkspaceSetting::query()->updateOrCreate([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => $domain, + 'key' => $key, + ], [ + 'value' => $normalizedValue, + 'updated_by_user_id' => $updatedByUserId, + ]); + + return [ + 'setting' => $setting, + 'before_value' => $beforeValue, + ]; + } + private function validatedValue(SettingDefinition $definition, mixed $value): mixed { $validator = Validator::make( diff --git a/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php b/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php index caf32763..0150831e 100644 --- a/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php +++ b/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php @@ -17,7 +17,6 @@ final class OperationRunTriageService 'inventory.sync', 'policy.sync', 'directory.groups.sync', - 'findings.lifecycle.backfill', 'rbac.health_check', 'entra.admin_roles.scan', 'tenant.review_pack.generate', @@ -28,7 +27,6 @@ final class OperationRunTriageService 'inventory.sync', 'policy.sync', 'directory.groups.sync', - 'findings.lifecycle.backfill', 'rbac.health_check', 'entra.admin_roles.scan', 'tenant.review_pack.generate', diff --git a/apps/platform/app/Support/Auth/PlatformCapabilities.php b/apps/platform/app/Support/Auth/PlatformCapabilities.php index e1575640..82b67dd3 100644 --- a/apps/platform/app/Support/Auth/PlatformCapabilities.php +++ b/apps/platform/app/Support/Auth/PlatformCapabilities.php @@ -18,6 +18,8 @@ class PlatformCapabilities public const DIRECTORY_VIEW = 'platform.directory.view'; + public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage'; + public const OPERATIONS_VIEW = 'platform.operations.view'; public const OPERATIONS_MANAGE = 'platform.operations.manage'; @@ -28,8 +30,6 @@ class PlatformCapabilities public const RUNBOOKS_RUN = 'platform.runbooks.run'; - public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill'; - public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage'; /** diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index 3c4daf1d..daf3b3b0 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -57,6 +57,7 @@ final class BadgeCatalog BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, + BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class, BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index 69a0f4b1..ab865692 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -48,6 +48,7 @@ enum BadgeDomain: string case BaselineProfileStatus = 'baseline_profile_status'; case FindingType = 'finding_type'; case ReviewPackStatus = 'review_pack_status'; + case CommercialLifecycleState = 'commercial_lifecycle_state'; case EvidenceSnapshotStatus = 'evidence_snapshot_status'; case EvidenceCompleteness = 'evidence_completeness'; case TenantReviewStatus = 'tenant_review_status'; diff --git a/apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php b/apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php new file mode 100644 index 00000000..e9545e98 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php @@ -0,0 +1,26 @@ + new BadgeSpec('Trial', 'info', 'heroicon-m-clock'), + WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'), + WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'), + WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php new file mode 100644 index 00000000..bc8e778d --- /dev/null +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -0,0 +1,888 @@ + + */ + private const FAMILY_ORDER = [ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ]; + + public function __construct( + private TenantBackupHealthResolver $backupHealthResolver, + private RestoreSafetyResolver $restoreSafetyResolver, + private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver, + private TenantReviewRegisterService $tenantReviewRegisterService, + ) {} + + /** + * @param array $authorizedTenants + * @param array $visibleFindingTenants + * @param array $reviewTenants + * @return array{ + * sections: list>, + * available_families: list, + * family_counts: array, + * total_count: int, + * } + */ + public function build( + User $user, + Workspace $workspace, + array $authorizedTenants, + array $visibleFindingTenants, + array $reviewTenants, + bool $canViewAlerts, + ?Tenant $selectedTenant = null, + ?string $selectedFamily = null, + ?CanonicalNavigationContext $navigationContext = null, + ): array { + $authorizedTenantsById = $this->indexTenants($authorizedTenants); + $visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants); + $reviewTenantsById = $this->indexTenants($reviewTenants); + + $allSections = []; + $availableFamilies = []; + $familyCounts = []; + + if ($visibleFindingTenantsById !== []) { + $assignedSection = $this->assignedFindingsSection( + user: $user, + visibleFindingTenants: $visibleFindingTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$assignedSection['key']] = $assignedSection; + $availableFamilies[] = [ + 'key' => $assignedSection['key'], + 'label' => $assignedSection['label'], + 'count' => $assignedSection['count'], + ]; + $familyCounts[$assignedSection['key']] = $assignedSection['count']; + + $intakeSection = $this->intakeFindingsSection( + visibleFindingTenants: $visibleFindingTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$intakeSection['key']] = $intakeSection; + $availableFamilies[] = [ + 'key' => $intakeSection['key'], + 'label' => $intakeSection['label'], + 'count' => $intakeSection['count'], + ]; + $familyCounts[$intakeSection['key']] = $intakeSection['count']; + } + + if ($authorizedTenantsById !== []) { + $operationsSection = $this->operationsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$operationsSection['key']] = $operationsSection; + $availableFamilies[] = [ + 'key' => $operationsSection['key'], + 'label' => $operationsSection['label'], + 'count' => $operationsSection['count'], + ]; + $familyCounts[$operationsSection['key']] = $operationsSection['count']; + } + + if ($canViewAlerts) { + $alertsSection = $this->alertsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$alertsSection['key']] = $alertsSection; + $availableFamilies[] = [ + 'key' => $alertsSection['key'], + 'label' => $alertsSection['label'], + 'count' => $alertsSection['count'], + ]; + $familyCounts[$alertsSection['key']] = $alertsSection['count']; + } + + if ($reviewTenantsById !== []) { + $reviewSection = $this->reviewFollowUpSection( + user: $user, + workspace: $workspace, + reviewTenants: $reviewTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$reviewSection['key']] = $reviewSection; + $availableFamilies[] = [ + 'key' => $reviewSection['key'], + 'label' => $reviewSection['label'], + 'count' => $reviewSection['count'], + ]; + $familyCounts[$reviewSection['key']] = $reviewSection['count']; + } + + $sections = []; + + foreach (self::FAMILY_ORDER as $familyKey) { + $section = $allSections[$familyKey] ?? null; + + if (! is_array($section)) { + continue; + } + + if ($selectedFamily !== null) { + if ($familyKey === $selectedFamily) { + $sections[] = $section; + } + + continue; + } + + if ((int) ($section['count'] ?? 0) > 0) { + $sections[] = $section; + } + } + + return [ + 'sections' => $sections, + 'available_families' => $availableFamilies, + 'family_counts' => $familyCounts, + 'total_count' => array_sum($familyCounts), + ]; + } + + /** + * @param array $tenants + * @return array + */ + private function indexTenants(array $tenants): array + { + $indexed = []; + + foreach ($tenants as $tenant) { + $indexed[(int) $tenant->getKey()] = $tenant; + } + + return $indexed; + } + + /** + * @param array $visibleFindingTenants + * @return array + */ + private function assignedFindingsSection( + User $user, + array $visibleFindingTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $overdueCount = (clone $baseQuery) + ->whereNotNull('due_at') + ->where('due_at', '<', now()) + ->count(); + $entries = $this->orderedAssignedFindingsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10)) + ->all(); + + return [ + 'key' => 'assigned_findings', + 'label' => 'Assigned findings', + 'count' => $count, + 'summary' => $this->assignedFindingsSummary($count, $overdueCount), + 'dominant_action_label' => 'Open my findings', + 'dominant_action_url' => $this->appendQuery( + MyFindingsInbox::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + ], static fn (mixed $value): bool => is_string($value) && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No assigned findings match this tenant filter right now.' + : 'No assigned findings are visible right now.', + ]; + } + + /** + * @param array $visibleFindingTenants + * @return array + */ + private function intakeFindingsSection( + array $visibleFindingTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $needsTriageCount = (clone $baseQuery) + ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) + ->count(); + $entries = $this->orderedIntakeFindingsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20)) + ->all(); + + return [ + 'key' => 'intake_findings', + 'label' => 'Findings intake', + 'count' => $count, + 'summary' => $this->intakeFindingsSummary($count, $needsTriageCount), + 'dominant_action_label' => 'Open findings intake', + 'dominant_action_url' => $this->appendQuery( + FindingsIntakeQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + 'view' => $needsTriageCount > 0 ? 'needs_triage' : null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No intake findings match this tenant filter right now.' + : 'No intake findings are visible right now.', + ]; + } + + /** + * @param array $authorizedTenants + * @return array + */ + private function operationsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant); + $staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant); + $terminalCount = (clone $terminalQuery)->count(); + $staleCount = (clone $staleQuery)->count(); + $entries = array_merge( + (clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(), + (clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(), + ); + $entries = collect($entries) + ->unique(fn (OperationRun $run): int => (int) $run->getKey()) + ->sortBy([ + fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1, + fn (OperationRun $run): int => -1 * (int) $run->getKey(), + ]) + ->take(self::PREVIEW_LIMIT) + ->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext)) + ->values() + ->all(); + $dominantProblemClass = $terminalCount > 0 + ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + : OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION; + + return [ + 'key' => 'stale_operations', + 'label' => 'Operations follow-up', + 'count' => $terminalCount + $staleCount, + 'summary' => $this->operationsSummary($terminalCount, $staleCount), + 'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations', + 'dominant_action_url' => OperationRunLinks::index( + tenant: $selectedTenant, + context: $navigationContext, + problemClass: $dominantProblemClass, + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No stale or terminal follow-up operations match this tenant filter right now.' + : 'No stale or terminal follow-up operations are visible right now.', + ]; + } + + /** + * @param array $authorizedTenants + * @return array + */ + private function alertsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $entries = (clone $baseQuery) + ->latest('created_at') + ->latest('id') + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext)) + ->all(); + + return [ + 'key' => 'alert_delivery_failures', + 'label' => 'Alert delivery failures', + 'count' => $count, + 'summary' => $this->alertsSummary($count), + 'dominant_action_label' => 'Open alert deliveries', + 'dominant_action_url' => $this->appendQuery( + AlertDeliveryResource::getUrl(panel: 'admin'), + array_replace_recursive( + $navigationContext?->toQuery() ?? [], + [ + 'tableFilters' => array_filter([ + 'status' => ['value' => AlertDelivery::STATUS_FAILED], + 'tenant_id' => $selectedTenant instanceof Tenant + ? ['value' => (string) $selectedTenant->getKey()] + : null, + ], static fn (mixed $value): bool => $value !== null), + ], + ), + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No failed alert deliveries match this tenant filter right now.' + : 'No failed alert deliveries are visible right now.', + ]; + } + + /** + * @param array $reviewTenants + * @return array + */ + private function reviewFollowUpSection( + User $user, + Workspace $workspace, + array $reviewTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($reviewTenants); + $backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds); + $recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant); + $resolved = $this->tenantTriageReviewStateResolver->resolveMany( + workspaceId: (int) $workspace->getKey(), + tenantIds: $tenantIds, + backupHealthByTenant: $backupHealthByTenant, + recoveryEvidenceByTenant: $recoveryEvidenceByTenant, + ); + $latestPublishedReviews = $this->tenantReviewRegisterService + ->latestPublishedQuery($user, $workspace) + ->get() + ->keyBy('tenant_id') + ->all(); + + $rawEntries = []; + + foreach ($tenantIds as $tenantId) { + $tenant = $reviewTenants[$tenantId] ?? null; + $rows = $resolved['rows'][$tenantId] ?? null; + + if (! $tenant instanceof Tenant || ! is_array($rows)) { + continue; + } + + foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) { + $row = $rows[$family] ?? null; + + if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) { + continue; + } + + $derivedState = $row['derived_state'] ?? null; + + if (! in_array($derivedState, [ + TenantTriageReview::STATE_FOLLOW_UP_NEEDED, + TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, + ], true)) { + continue; + } + + $rawEntries[] = $this->reviewEntry( + tenant: $tenant, + family: $family, + row: $row, + latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null, + navigationContext: $navigationContext, + ); + } + } + + usort($rawEntries, function (array $left, array $right): int { + $leftRank = (int) ($left['urgency_rank'] ?? 0); + $rightRank = (int) ($right['urgency_rank'] ?? 0); + + if ($leftRank !== $rightRank) { + return $leftRank <=> $rightRank; + } + + return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? '')); + }); + + $followUpCount = collect($rawEntries) + ->where('status_label', 'Follow-up needed') + ->count(); + $changedCount = collect($rawEntries) + ->where('status_label', 'Changed since review') + ->count(); + + return [ + 'key' => 'review_follow_up', + 'label' => 'Review follow-up', + 'count' => count($rawEntries), + 'summary' => $this->reviewSummary($followUpCount, $changedCount), + 'dominant_action_label' => 'Open review follow-up', + 'dominant_action_url' => $selectedTenant instanceof Tenant + ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) + : $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( + $navigationContext?->toQuery() ?? [], + [ + 'backup_posture' => [ + TenantBackupHealthAssessment::POSTURE_ABSENT, + TenantBackupHealthAssessment::POSTURE_STALE, + TenantBackupHealthAssessment::POSTURE_DEGRADED, + ], + 'recovery_evidence' => [ + TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED, + TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED, + ], + 'review_state' => [ + TenantTriageReview::STATE_FOLLOW_UP_NEEDED, + TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, + ], + 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ], + )), + 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No review follow-up is visible for this tenant filter right now.' + : 'No review follow-up is visible right now.', + ]; + } + + /** + * @param array $visibleFindingTenants + */ + private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($visibleFindingTenants); + + return Finding::query() + ->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name']) + ->withSubjectDisplayName() + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->where('assignee_user_id', (int) $user->getKey()) + ->whereIn('status', Finding::openStatusesForQuery()); + } + + private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + 'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc', + [now()], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + /** + * @param array $visibleFindingTenants + */ + private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($visibleFindingTenants); + + return Finding::query() + ->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name']) + ->withSubjectDisplayName() + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->whereNull('assignee_user_id') + ->whereIn('status', Finding::openStatusesForQuery()); + } + + private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + "case + when due_at is not null and due_at < ? then 0 + when status = ? then 1 + when status = ? then 2 + else 3 + end asc", + [now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + /** + * @param array $authorizedTenants + */ + private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant) + ->terminalFollowUp(); + } + + /** + * @param array $authorizedTenants + */ + private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant) + ->activeStaleAttention(); + } + + /** + * @param array $authorizedTenants + */ + private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = array_keys($authorizedTenants); + + return OperationRun::query() + ->with('tenant') + ->where('workspace_id', (int) $workspace->getKey()) + ->where(function ($query) use ($selectedTenant, $tenantIds): void { + if ($selectedTenant instanceof Tenant) { + $query->where('tenant_id', (int) $selectedTenant->getKey()); + + return; + } + + $query + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->orWhereNull('tenant_id'); + }); + } + + /** + * @param array $authorizedTenants + */ + private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = array_keys($authorizedTenants); + + return AlertDelivery::query() + ->with('tenant') + ->where('workspace_id', (int) $workspace->getKey()) + ->where('status', AlertDelivery::STATUS_FAILED) + ->where(function ($query) use ($selectedTenant, $tenantIds): void { + if ($selectedTenant instanceof Tenant) { + $query->where('tenant_id', (int) $selectedTenant->getKey()); + + return; + } + + $query + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->orWhereNull('tenant_id'); + }); + } + + /** + * @return array + */ + private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array + { + $sublineParts = array_values(array_filter([ + $finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null, + FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding), + $finding->reopened_at !== null ? 'Reopened' : null, + ])); + + return [ + 'family_key' => $familyKey, + 'source_model' => Finding::class, + 'source_key' => (string) $finding->getKey(), + 'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null, + 'tenant_label' => $finding->tenant?->name, + 'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(), + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => $baseUrgencyRank + + ($finding->due_at?->isPast() === true ? 0 : 1) + + ($finding->reopened_at !== null ? 0 : 1), + 'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(), + 'destination_url' => $this->appendQuery( + FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @return array + */ + private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array + { + $problemClass = $run->problemClass(); + + return [ + 'family_key' => 'stale_operations', + 'source_model' => OperationRun::class, + 'source_key' => (string) $run->getKey(), + 'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null, + 'tenant_label' => $run->tenant?->name, + 'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + ? 'Terminal follow-up operation' + : 'Stale active operation', + 'subline' => OperationRunLinks::identifier($run), + 'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1, + 'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + ? 'Terminal follow-up' + : 'Stale', + 'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @return array + */ + private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array + { + $payload = is_array($delivery->payload) ? $delivery->payload : []; + $headline = is_string($payload['title'] ?? null) && $payload['title'] !== '' + ? (string) $payload['title'] + : 'Failed alert delivery'; + $sublineParts = array_values(array_filter([ + is_string($delivery->last_error_message) && $delivery->last_error_message !== '' + ? $delivery->last_error_message + : null, + is_string($delivery->event_type) && $delivery->event_type !== '' + ? $delivery->event_type + : null, + ])); + + return [ + 'family_key' => 'alert_delivery_failures', + 'source_model' => AlertDelivery::class, + 'source_key' => (string) $delivery->getKey(), + 'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null, + 'tenant_label' => $delivery->tenant?->name, + 'headline' => $headline, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => 0, + 'status_label' => 'Failed', + 'destination_url' => $this->appendQuery( + AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @param array $row + * @return array + */ + private function reviewEntry( + Tenant $tenant, + string $family, + array $row, + mixed $latestPublishedReview, + ?CanonicalNavigationContext $navigationContext, + ): array { + $state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED); + $familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH + ? 'Backup health' + : 'Recovery evidence'; + $headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED + ? $familyLabel.' needs review follow-up' + : $familyLabel.' changed since review'; + $sublineParts = array_values(array_filter([ + is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== '' + ? 'Last review: '.$row['reviewed_by_user_name'] + : null, + isset($row['reviewed_at']) && $row['reviewed_at'] !== null + ? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString() + : null, + ])); + $destinationUrl = $latestPublishedReview !== null + ? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant') + : CustomerReviewWorkspace::tenantPrefilterUrl($tenant); + + return [ + 'family_key' => 'review_follow_up', + 'source_model' => TenantTriageReview::class, + 'source_key' => (string) $tenant->getKey().':'.$family, + 'tenant_id' => (int) $tenant->getKey(), + 'tenant_label' => $tenant->name, + 'headline' => $headline, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1, + 'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED + ? 'Follow-up needed' + : 'Changed since review', + 'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + private function assignedFindingsSummary(int $count, int $overdueCount): string + { + if ($count === 0) { + return 'No assigned findings are visible in the current scope.'; + } + + if ($overdueCount > 0) { + return sprintf( + '%d assigned finding%s remain open. %d %s overdue.', + $count, + $count === 1 ? '' : 's', + $overdueCount, + $overdueCount === 1 ? 'is' : 'are', + ); + } + + return sprintf( + '%d assigned finding%s remain open in the visible scope.', + $count, + $count === 1 ? '' : 's', + ); + } + + private function intakeFindingsSummary(int $count, int $needsTriageCount): string + { + if ($count === 0) { + return 'No intake findings are visible in the current scope.'; + } + + return sprintf( + '%d unassigned finding%s remain in intake. %d still need first triage.', + $count, + $count === 1 ? '' : 's', + $needsTriageCount, + ); + } + + private function operationsSummary(int $terminalCount, int $staleCount): string + { + if ($terminalCount + $staleCount === 0) { + return 'No stale or terminal follow-up operations are visible in the current scope.'; + } + + if ($terminalCount > 0 && $staleCount > 0) { + return sprintf( + '%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.', + $terminalCount, + $terminalCount === 1 ? '' : 's', + $staleCount, + $staleCount === 1 ? '' : 's', + ); + } + + if ($terminalCount > 0) { + return sprintf( + '%d terminal follow-up operation%s need monitoring attention.', + $terminalCount, + $terminalCount === 1 ? '' : 's', + ); + } + + return sprintf( + '%d stale active run%s need monitoring attention.', + $staleCount, + $staleCount === 1 ? '' : 's', + ); + } + + private function alertsSummary(int $count): string + { + if ($count === 0) { + return 'No failed alert deliveries are visible in the current scope.'; + } + + return sprintf( + '%d failed alert delivery attempt%s remain visible in this workspace.', + $count, + $count === 1 ? '' : 's', + ); + } + + private function reviewSummary(int $followUpCount, int $changedCount): string + { + $total = $followUpCount + $changedCount; + + if ($total === 0) { + return 'No review follow-up is visible in the current scope.'; + } + + return sprintf( + '%d review concern%s need attention. %d marked follow-up needed and %d changed since review.', + $total, + $total === 1 ? '' : 's', + $followUpCount, + $changedCount, + ); + } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url.$separator.http_build_query($query); + } +} \ No newline at end of file diff --git a/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php b/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php index 469ce525..28e97fca 100644 --- a/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php +++ b/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php @@ -12,8 +12,6 @@ final class TrustedStatePolicy public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions'; - public const SYSTEM_RUNBOOKS = 'system_runbooks'; - /** * @return array{ * name: string, @@ -329,92 +327,6 @@ public function firstSlice(): array 'scopedTenant', ], ], - self::SYSTEM_RUNBOOKS => [ - 'component_name' => 'System runbooks', - 'plane' => 'system_platform', - 'route_anchor' => null, - 'authority_sources' => [ - 'allowed_tenant_universe', - 'explicit_scoped_query', - ], - 'locked_identities' => [], - 'locked_identity_fields' => [], - 'mutable_selectors' => [ - 'findingsTenantId', - 'tenantId', - 'findingsScopeMode', - 'scopeMode', - ], - 'mutable_selector_fields' => [ - $this->field( - name: 'findingsTenantId', - stateClass: TrustedStateClass::Presentation, - phpType: '?int', - sourceOfTruth: 'allowed_tenant_universe', - usedForProtectedAction: true, - revalidationRequired: true, - implementationMarkers: [ - 'public ?int $findingsTenantId = null;', - 'resolveAllowedOrFail($this->findingsTenantId)', - ], - notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.', - ), - $this->field( - name: 'tenantId', - stateClass: TrustedStateClass::Presentation, - phpType: '?int', - sourceOfTruth: 'allowed_tenant_universe', - usedForProtectedAction: false, - revalidationRequired: false, - implementationMarkers: [ - 'public ?int $tenantId = null;', - ], - notes: 'Mirrored display state for the last trusted preflight result.', - ), - $this->field( - name: 'findingsScopeMode', - stateClass: TrustedStateClass::Presentation, - phpType: 'string', - sourceOfTruth: 'presentation_only', - usedForProtectedAction: true, - revalidationRequired: true, - implementationMarkers: [ - 'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;', - 'trustedFindingsScopeFromState(', - ], - notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.', - ), - $this->field( - name: 'scopeMode', - stateClass: TrustedStateClass::Presentation, - phpType: 'string', - sourceOfTruth: 'presentation_only', - usedForProtectedAction: false, - revalidationRequired: false, - implementationMarkers: [ - 'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;', - ], - notes: 'Mirrored display state for the last trusted preflight result.', - ), - ], - 'server_derived_authority_fields' => [ - $this->field( - name: 'findingsScope', - stateClass: TrustedStateClass::ServerDerivedAuthority, - phpType: 'FindingsLifecycleBackfillScope', - sourceOfTruth: 'allowed_tenant_universe', - usedForProtectedAction: true, - revalidationRequired: true, - implementationMarkers: [ - 'trustedFindingsScopeFromFormData(', - 'trustedFindingsScopeFromState(', - 'resolveAllowedOrFail(', - ], - notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.', - ), - ], - 'forbidden_public_authority_fields' => [], - ], ]; } diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index e1d649ca..dd5b31ba 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -278,7 +278,6 @@ private static function canonicalDefinitions(): array 'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60), 'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120), 'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30), - 'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300), ]; } @@ -331,7 +330,6 @@ private static function operationAliases(): array new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true), new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true), new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true), - new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true), ]; } } diff --git a/apps/platform/app/Support/Settings/SettingsRegistry.php b/apps/platform/app/Support/Settings/SettingsRegistry.php index 694a930e..dd645b05 100644 --- a/apps/platform/app/Support/Settings/SettingsRegistry.php +++ b/apps/platform/app/Support/Settings/SettingsRegistry.php @@ -4,8 +4,10 @@ namespace App\Support\Settings; -use App\Support\Ai\AiPolicyMode; use App\Models\Finding; +use App\Services\Localization\LocaleResolver; +use App\Support\Ai\AiPolicyMode; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspacePlanProfileCatalog; final class SettingsRegistry @@ -28,6 +30,25 @@ public function __construct() normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)), )); + $this->register(new SettingDefinition( + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + type: 'string', + systemDefault: null, + rules: [ + 'nullable', + 'string', + 'in:'.implode(',', LocaleResolver::supportedLocales()), + ], + normalizer: static function (mixed $value): ?string { + if ($value === null) { + return null; + } + + return LocaleResolver::normalize($value); + }, + )); + $this->register(new SettingDefinition( domain: 'backup', key: 'retention_keep_last_default', @@ -314,6 +335,44 @@ static function (string $attribute, mixed $value, \Closure $fail): void { return $normalized === '' ? null : $normalized; }, )); + + $this->register(new SettingDefinition( + domain: 'entitlements', + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + type: 'string', + systemDefault: null, + rules: [ + 'nullable', + 'string', + 'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()), + ], + normalizer: static function (mixed $value): ?string { + if ($value === null) { + return null; + } + + $normalized = strtolower(trim((string) $value)); + + return $normalized === '' ? null : $normalized; + }, + )); + + $this->register(new SettingDefinition( + domain: 'entitlements', + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, + type: 'string', + systemDefault: null, + rules: ['nullable', 'string', 'max:500'], + normalizer: static function (mixed $value): ?string { + if ($value === null) { + return null; + } + + $normalized = trim((string) $value); + + return $normalized === '' ? null : $normalized; + }, + )); } /** diff --git a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index 1bf5193d..8f6df367 100644 --- a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -640,23 +640,18 @@ public static function spec195ResidualSurfaceInventory(): array 'discoveryState' => 'outside_primary_discovery', 'closureDecision' => 'separately_governed', 'reasonCategory' => 'workflow_specific_governance', - 'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.', + 'explicitReason' => 'Runbooks remains a system utility shell outside the declaration-backed record or table surface; it currently exposes no supported launch action after lifecycle-backfill removal.', 'evidence' => [ [ 'kind' => 'feature_livewire_test', - 'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php', - 'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.', + 'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php', + 'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.', ], [ 'kind' => 'authorization_test', 'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php', 'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.', ], - [ - 'kind' => 'guard_test', - 'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php', - 'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.', - ], ], 'followUpAction' => 'add_guard_only', 'mustRemainBaselineExempt' => false, @@ -749,12 +744,17 @@ public static function spec195ResidualSurfaceInventory(): array 'discoveryState' => 'outside_primary_discovery', 'closureDecision' => 'harmless_special_case', 'reasonCategory' => 'read_mostly_context_detail', - 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.', + 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.', 'evidence' => [ [ 'kind' => 'feature_livewire_test', 'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php', - 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.', + 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.', + ], + [ + 'kind' => 'feature_livewire_test', + 'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php', + 'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.', ], [ 'kind' => 'authorization_test', diff --git a/apps/platform/app/Support/Workspaces/WorkspaceResolver.php b/apps/platform/app/Support/Workspaces/WorkspaceResolver.php index 5535db85..92b1108b 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceResolver.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceResolver.php @@ -8,6 +8,8 @@ final class WorkspaceResolver { public function resolve(string $value): ?Workspace { + $value = $this->normalizeRouteValue($value); + $workspace = Workspace::query() ->where('slug', $value) ->first(); @@ -22,4 +24,37 @@ public function resolve(string $value): ?Workspace return Workspace::query()->whereKey((int) $value)->first(); } + + private function normalizeRouteValue(string $value): string + { + $value = trim($value); + + if (! str_starts_with($value, '{')) { + return $value; + } + + $decoded = json_decode($value, true); + + if (! is_array($decoded)) { + return $value; + } + + $slug = $decoded['slug'] ?? null; + + if (is_string($slug) && $slug !== '') { + return $slug; + } + + $id = $decoded['id'] ?? null; + + if (is_int($id)) { + return (string) $id; + } + + if (is_string($id) && ctype_digit($id)) { + return $id; + } + + return $value; + } } diff --git a/apps/platform/bootstrap/app.php b/apps/platform/bootstrap/app.php index fc43bdff..7ee22e95 100644 --- a/apps/platform/bootstrap/app.php +++ b/apps/platform/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use App\Http\Middleware\ApplyResolvedLocale; use App\Http\Middleware\SuppressDebugbarForSmokeRequests; use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests; @@ -24,7 +25,12 @@ UseSystemSessionCookieForLivewireRequests::class, ]); + $middleware->web(append: [ + ApplyResolvedLocale::class, + ]); + $middleware->alias([ + 'apply-resolved-locale' => ApplyResolvedLocale::class, 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, diff --git a/apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php b/apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php new file mode 100644 index 00000000..5da4f016 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php @@ -0,0 +1,25 @@ +string('preferred_locale', 8) + ->nullable() + ->after('last_workspace_id') + ->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('preferred_locale'); + }); + } +}; diff --git a/apps/platform/database/seeders/PlatformUserSeeder.php b/apps/platform/database/seeders/PlatformUserSeeder.php index 3585c630..f81b9924 100644 --- a/apps/platform/database/seeders/PlatformUserSeeder.php +++ b/apps/platform/database/seeders/PlatformUserSeeder.php @@ -41,7 +41,6 @@ public function run(): void PlatformCapabilities::OPS_VIEW, PlatformCapabilities::RUNBOOKS_VIEW, PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, PlatformCapabilities::OPS_CONTROLS_MANAGE, ], 'is_active' => true, diff --git a/apps/platform/lang/de/baseline-compare.php b/apps/platform/lang/de/baseline-compare.php new file mode 100644 index 00000000..bbc2dfd7 --- /dev/null +++ b/apps/platform/lang/de/baseline-compare.php @@ -0,0 +1,88 @@ + 'Warnung', + 'duplicate_warning_body_plural' => ':count Policies in diesem Tenant verwenden generische Anzeigenamen, dadurch entstehen :ambiguous_count mehrdeutige Subjekte. :app kann sie nicht sicher mit der Baseline abgleichen.', + 'duplicate_warning_body_singular' => ':count Policy in diesem Tenant verwendet einen generischen Anzeigenamen, dadurch entsteht :ambiguous_count mehrdeutiges Subjekt. :app kann es nicht sicher mit der Baseline abgleichen.', + 'stat_assigned_baseline' => 'Zugewiesene Baseline', + 'stat_total_findings' => 'Findings gesamt', + 'stat_last_compared' => 'Zuletzt verglichen', + 'stat_last_compared_never' => 'Nie', + 'stat_error' => 'Fehler', + 'badge_snapshot' => 'Snapshot #:id', + 'badge_coverage_ok' => 'Abdeckung: OK', + 'badge_coverage_warnings' => 'Abdeckung: Warnungen', + 'badge_fidelity' => 'Fidelity: :level', + 'badge_evidence_gaps' => 'Evidence Gaps: :count', + 'evidence_gaps_tooltip' => 'Wichtigste Gaps: :summary', + 'evidence_gap_details_heading' => 'Evidence-Gap-Details', + 'evidence_gap_details_description' => 'Durchsuchen Sie aufgezeichnete Gap-Subjekte nach Grund, Governed Subject, Subjektklasse, Ergebnis, nächster Aktion oder Subject Key, bevor Sie Rohdiagnosen verwenden.', + 'evidence_gap_search_label' => 'Gap-Details suchen', + 'evidence_gap_search_placeholder' => 'Nach Grund, Typ, Klasse, Ergebnis, Aktion oder Subject Key suchen', + 'evidence_gap_search_help' => 'Filtert über Grund, Governed Subject, Subjektklasse, Ergebnis, nächste Aktion und Subject Key.', + 'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensätze passten zum gleichen Policy-Subjekt. Prüfen Sie das Mapping.', + 'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Prüfen Sie, ob die Policy im Tenant noch existiert.', + 'evidence_gap_bucket_help_inventory_record_missing' => 'Für diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Prüfen Sie, ob der Inventory Sync aktuell ist.', + 'evidence_gap_bucket_help_foundation_not_policy_backed' => 'Diese Subjekte existieren in der Foundation-Schicht, sind aber nicht durch eine verwaltete Policy abgedeckt. Prüfen Sie, ob eine Policy erstellt werden sollte.', + 'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist für diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder prüfen Sie die Graph-Konnektivität.', + 'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Prüfen Sie die betroffenen Zeilen.', + 'evidence_gap_reason' => 'Grund', + 'evidence_gap_reason_affected' => ':count betroffen', + 'evidence_gap_reason_recorded' => ':count aufgezeichnet', + 'evidence_gap_reason_missing_detail' => ':count ohne Detail', + 'evidence_gap_structural' => 'Strukturell: :count', + 'evidence_gap_operational' => 'Operativ: :count', + 'evidence_gap_transient' => 'Temporär: :count', + 'evidence_gap_bucket_structural' => ':count strukturell', + 'evidence_gap_bucket_operational' => ':count operativ', + 'evidence_gap_bucket_transient' => ':count temporär', + 'evidence_gap_missing_details_title' => 'Für diesen Run wurden keine Detailzeilen aufgezeichnet', + 'evidence_gap_missing_details_body' => 'Evidence Gaps wurden für diesen Compare Run gezählt, aber Details auf Subjektebene wurden nicht gespeichert. Prüfen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.', + 'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden für diesen Grund gezählt, aber Detailzeilen wurden nicht aufgezeichnet.', + 'evidence_gap_legacy_title' => 'Legacy-Development-Gap-Payload erkannt', + 'evidence_gap_legacy_body' => 'Dieser Run verwendet noch die retired breite Grundform. Erzeugen Sie den Run neu oder bereinigen Sie alte lokale Development-Payloads.', + 'evidence_gap_diagnostics_heading' => 'Baseline-Compare-Evidence', + 'evidence_gap_diagnostics_description' => 'Rohdiagnosen bleiben für Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfügbar.', + 'evidence_gap_policy_type' => 'Governed Subject', + 'evidence_gap_subject_class' => 'Subjektklasse', + 'evidence_gap_outcome' => 'Ergebnis', + 'evidence_gap_next_action' => 'Nächste Aktion', + 'evidence_gap_subject_key' => 'Subject Key', + 'evidence_gap_table_empty_heading' => 'Keine aufgezeichneten Gap-Zeilen passen zu dieser Ansicht', + 'evidence_gap_table_empty_description' => 'Passen Sie Suche oder Filter an, um andere betroffene Subjekte zu prüfen.', + 'comparing_indicator' => 'Vergleich läuft...', + 'no_findings_all_clear' => 'Kein bestätigter Drift im letzten Vergleich', + 'no_findings_coverage_warnings' => 'Kein Drift angezeigt, aber Coverage limitiert diesen Vergleich', + 'no_findings_evidence_gaps' => 'Kein Drift angezeigt, aber Evidence Gaps müssen geprüft werden', + 'no_findings_default' => 'Aktuell sind keine Drift Findings sichtbar', + 'coverage_warning_title' => 'Vergleich mit Warnungen abgeschlossen', + 'coverage_unproven_body' => 'Coverage Proof fehlte oder war nicht lesbar. Findings wurden aus Sicherheitsgründen unterdrückt.', + 'coverage_incomplete_body' => 'Findings wurden für :count Policy :types wegen unvollständiger Coverage übersprungen.', + 'coverage_uncovered_label' => 'Nicht abgedeckt: :list', + 'failed_title' => 'Vergleich fehlgeschlagen', + 'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Prüfen Sie die Run-Details oder wiederholen Sie ihn.', + 'critical_drift_title' => 'Kritischer Drift erkannt', + 'critical_drift_body' => 'Der aktuelle Tenant-Zustand weicht von Baseline :profile ab. :count High-Severity :findings erfordern sofortige Aufmerksamkeit.', + 'empty_no_tenant' => 'Kein Tenant ausgewählt', + 'empty_no_assignment' => 'Keine Baseline zugewiesen', + 'empty_no_snapshot' => 'Kein Snapshot verfügbar', + 'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.', + 'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen', + 'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.', + 'rbac_summary_compared' => 'Verglichen', + 'rbac_summary_unchanged' => 'Unverändert', + 'rbac_summary_modified' => 'Geändert', + 'rbac_summary_missing' => 'Fehlend', + 'rbac_summary_unexpected' => 'Unerwartet', + 'no_drift_title' => 'Kein Drift erkannt', + 'no_drift_body' => 'Der letzte Vergleich hat keinen bestätigten Drift für das zugewiesene Baseline-Profil aufgezeichnet.', + 'coverage_warnings_title' => 'Coverage-Warnungen', + 'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestätigten Drift Findings. Aktualisieren Sie Evidence, bevor Sie dies als Entwarnung werten.', + 'idle_title' => 'Bereit zum Vergleich', + 'button_view_run' => 'Run anzeigen', + 'button_view_failed_run' => 'Fehlgeschlagenen Run anzeigen', + 'button_view_findings' => 'Alle Findings anzeigen', + 'button_review_last_run' => 'Letzten Run prüfen', +]; diff --git a/apps/platform/lang/de/findings.php b/apps/platform/lang/de/findings.php new file mode 100644 index 00000000..83ed5fa4 --- /dev/null +++ b/apps/platform/lang/de/findings.php @@ -0,0 +1,31 @@ + [ + 'rbac_role_definition' => 'Intune-RBAC-Rollendefinitions-Drift', + ], + 'subject_types' => [ + 'policy' => 'Policy', + 'intuneRoleDefinition' => 'Intune-RBAC-Rollendefinition', + ], + 'rbac' => [ + 'detail_heading' => 'Intune-RBAC-Rollendefinitions-Drift', + 'detail_subheading' => 'Rollenzuweisungen sind nicht enthalten. RBAC-Restore wird nicht unterstützt.', + 'metadata_only' => 'Nur Metadaten geändert', + 'permission_change' => 'Berechtigung geändert', + 'missing' => 'Im aktuellen Tenant fehlend', + 'unexpected' => 'Unerwartet im aktuellen Tenant', + 'changed_fields' => 'Geänderte Felder', + 'baseline' => 'Baseline', + 'current' => 'Aktuell', + 'absent' => 'Nicht vorhanden', + 'role_source' => 'Rollenquelle', + 'permission_blocks' => 'Berechtigungsblöcke', + 'built_in' => 'Integriert', + 'custom' => 'Benutzerdefiniert', + 'assignments_excluded' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.', + 'restore_unsupported' => 'RBAC-Restore wird in diesem Release nicht unterstützt.', + ], +]; diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php new file mode 100644 index 00000000..4827d589 --- /dev/null +++ b/apps/platform/lang/de/localization.php @@ -0,0 +1,230 @@ + [ + 'en' => 'Englisch', + 'de' => 'Deutsch', + ], + 'source' => [ + 'explicit_override' => 'Sitzungsüberschreibung', + 'user_preference' => 'persönliche Einstellung', + 'workspace_default' => 'Workspace-Standard', + 'workspace_override' => 'Workspace-Überschreibung', + 'system_default' => 'Systemstandard', + ], + 'shell' => [ + 'language' => 'Sprache', + 'current_language' => 'Aktuelle Sprache', + 'language_source' => 'Quelle: :source', + 'temporary_override' => 'Temporäre Überschreibung', + 'switch_language' => 'Sprache wechseln', + 'clear_override' => 'Geerbte Sprache verwenden', + 'personal_preference' => 'Persönliche Einstellung', + 'save_preference' => 'Einstellung speichern', + 'inherit_workspace' => 'Workspace-Standard verwenden', + 'workspace' => 'Workspace', + 'choose_workspace' => 'Workspace auswählen', + 'switch_workspace' => 'Workspace wechseln', + 'workspace_home' => 'Workspace-Start', + 'tenant_scope' => 'Tenant-Kontext', + 'select_tenant' => 'Tenant auswählen', + 'selected_tenant' => 'Ausgewählter Tenant', + 'no_tenant_selected' => 'Kein Tenant ausgewählt', + 'switch_tenant' => 'Tenant wechseln', + 'clear_tenant_scope' => 'Tenant-Kontext löschen', + 'context_unavailable' => 'Kontext nicht verfügbar', + 'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.', + 'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.', + 'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.', + 'view_managed_tenants' => 'Managed Tenants anzeigen', + 'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.', + 'search_tenants' => 'Tenants suchen...', + 'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.', + ], + 'workspace' => [ + 'title' => 'Workspace-Einstellungen', + 'save' => 'Speichern', + 'reset' => 'Zurücksetzen', + 'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.', + 'no_workspace_override' => 'Keine Workspace-Überschreibung zum Zurücksetzen vorhanden.', + 'last_modified_by' => ':description - Zuletzt geändert von :user, :time.', + 'section' => 'Lokalisierung', + 'section_description' => 'Workspace-Standard für Benutzer ohne persönliche Spracheinstellung.', + 'default_locale_label' => 'Standardsprache', + 'default_locale_placeholder' => 'Nicht gesetzt (verwendet Systemstandard)', + 'default_locale_helper_unset' => 'Nicht gesetzt. Effektive Sprache: :locale (:source).', + 'default_locale_helper_set' => 'Effektive Sprache: :locale.', + ], + 'auth' => [ + 'microsoft_not_configured' => 'Microsoft-Anmeldung ist nicht konfiguriert.', + 'sign_in_microsoft' => 'Mit Microsoft anmelden', + 'tenant_admin_membership_required' => 'Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft.', + ], + 'navigation' => [ + 'findings' => 'Findings', + 'settings' => 'Einstellungen', + 'integrations' => 'Integrationen', + 'manage_workspaces' => 'Workspaces verwalten', + 'operations' => 'Operationen', + 'audit_log' => 'Audit-Log', + 'alerts' => 'Alerts', + 'governance' => 'Governance', + 'monitoring' => 'Monitoring', + 'dashboard' => 'Dashboard', + ], + 'dashboard' => [ + 'tenant_title' => 'Tenant-Dashboard', + 'system_title' => 'System-Dashboard', + 'request_support' => 'Support anfragen', + 'support_request_heading' => 'Support anfragen', + 'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.', + 'submit_request' => 'Anfrage senden', + 'included_context' => 'Enthaltener Kontext', + 'severity' => 'Schweregrad', + 'summary' => 'Zusammenfassung', + 'reproduction_notes' => 'Reproduktionshinweise', + 'contact_name' => 'Kontaktname', + 'contact_email' => 'Kontakt-E-Mail', + 'support_request_submitted' => 'Supportanfrage gesendet', + 'open_support_diagnostics' => 'Supportdiagnosen öffnen', + 'support_diagnostics' => 'Supportdiagnosen', + 'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.', + 'close' => 'Schließen', + 'time_window' => 'Zeitfenster', + 'window' => 'Fenster', + 'enter_break_glass' => 'Break-Glass-Modus aktivieren', + 'exit_break_glass' => 'Break-Glass beenden', + 'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert', + 'recovery_mode_ended' => 'Wiederherstellungsmodus beendet', + ], + 'review' => [ + 'reporting' => 'Berichte', + 'customer_reviews' => 'Kundenreviews', + 'customer_review_workspace' => 'Kundenreview-Workspace', + 'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace', + 'customer_workspace_intro' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.', + 'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.', + 'reviews' => 'Reviews', + 'clear_filters' => 'Filter löschen', + 'tenant' => 'Tenant', + 'latest_review' => 'Letztes Review', + 'key_findings' => 'Wichtige Findings', + 'accepted_risks' => 'Akzeptierte Risiken', + 'published' => 'Veröffentlicht', + 'review_pack' => 'Review-Pack', + 'open_latest_review' => 'Letztes Review öffnen', + 'download_review_pack' => 'Review-Pack herunterladen', + 'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht', + 'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.', + 'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.', + 'no_published_review' => 'Kein veröffentlichtes Review', + 'no_published_review_available' => 'Noch kein veröffentlichtes Review verfügbar', + 'no_findings_recorded' => 'Im veröffentlichten Review sind keine Findings erfasst.', + 'findings_count_summary' => ':count Findings im veröffentlichten Review zusammengefasst.', + 'findings_count_with_outcomes' => ':count Findings. Terminale Ergebnisse: :outcomes.', + 'no_accepted_risks_recorded' => 'Keine akzeptierten Risiken erfasst.', + 'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).', + 'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.', + 'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.', + 'unavailable' => 'Nicht verfügbar', + 'available' => 'Verfügbar', + 'outcome_summary' => 'Ergebniszusammenfassung', + 'review' => 'Review', + 'review_date' => 'Review-Datum', + 'completeness' => 'Vollständigkeit', + 'evidence_snapshot' => 'Evidence-Snapshot', + 'current_export' => 'Aktueller Export', + 'executive_posture' => 'Executive-Status', + 'sections' => 'Abschnitte', + 'details' => 'Details', + 'export_executive_pack' => 'Executive-Pack exportieren', + 'outcome' => 'Ergebnis', + 'export' => 'Export', + 'next_step' => 'Nächster Schritt', + 'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews', + 'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie für diesen Tenant zu starten.', + 'create_first_review' => 'Erstes Review erstellen', + 'create_review' => 'Review erstellen', + 'evidence_basis' => 'Evidence-Basis', + 'evidence_basis_helper' => 'Wählen Sie den verankerten Evidence-Snapshot für dieses Review.', + 'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.', + 'select_valid_evidence_snapshot' => 'Wählen Sie einen gültigen Evidence-Snapshot aus.', + 'unable_create_review' => 'Review kann nicht erstellt werden', + 'review_already_available' => 'Review bereits verfügbar', + 'review_already_available_body' => 'Ein passendes veränderbares Review ist für diese Evidence-Basis bereits vorhanden.', + 'view_review' => 'Review anzeigen', + 'open_operation' => 'Operation öffnen', + 'review_composing_background' => 'Das Review wird im Hintergrund zusammengestellt.', + 'unable_export_missing_context' => 'Review kann nicht exportiert werden - Kontext fehlt.', + 'export_already_queued_body' => 'Ein Executive-Pack-Export ist für dieses Review bereits eingereiht oder läuft.', + 'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfügbar', + 'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden', + 'executive_pack_already_available' => 'Executive-Pack bereits verfügbar', + 'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist für dieses Review bereits vorhanden.', + 'view_pack' => 'Pack anzeigen', + 'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.', + 'review_explanation' => 'Review-Erklärung', + 'reason_owner' => 'Reason Owner', + 'platform_core' => 'Platform Core', + 'platform_reason_family' => 'Platform-Reason-Familie', + 'compatibility' => 'Kompatibilität', + 'highlights' => 'Highlights', + 'next_actions' => 'Nächste Aktionen', + 'related_context' => 'Verwandter Kontext', + 'publication_readiness' => 'Veröffentlichungsreife', + 'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.', + 'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.', + 'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.', + 'key_entries' => 'Wichtige Einträge', + 'entry' => 'Eintrag', + 'follow_up' => 'Follow-up', + 'diagnostics' => 'Diagnosen', + 'result_meaning' => 'Ergebnisbedeutung', + 'result_trust' => 'Ergebnisvertrauen', + 'artifact_truth' => 'Artifact Truth', + 'no_action_needed' => 'Keine Aktion erforderlich', + 'count' => 'Anzahl', + 'guidance' => 'Orientierung', + 'findings' => 'Findings', + 'reports' => 'Berichte', + 'operations' => 'Operationen', + 'pending_verification' => 'Verifizierung ausstehend', + 'verified_cleared' => 'Verifiziert bereinigt', + 'terminal_outcomes' => 'Terminale Ergebnisse', + 'pending' => 'Ausstehend', + 'operation' => 'Operation', + 'operation_description' => 'Prüfen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.', + 'executive_pack' => 'Executive-Pack', + 'view_executive_pack' => 'Executive-Pack anzeigen', + 'executive_pack_description' => 'Öffnet den aktuellen Export, der zu diesem Review gehört.', + 'customer_workspace' => 'Kunden-Workspace', + 'open_customer_workspace' => 'Kunden-Workspace öffnen', + 'customer_workspace_description' => 'Öffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.', + 'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen', + 'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurückkehren.', + ], + 'findings' => [ + 'all' => 'Alle', + 'needs_action' => 'Handlungsbedarf', + 'overdue' => 'Überfällig', + 'risk_accepted' => 'Risiko akzeptiert', + 'resolved' => 'Gelöst', + 'actions' => 'Aktionen', + 'open_approval_queue' => 'Freigabewarteschlange öffnen', + ], + 'notifications' => [ + 'locale_override_saved' => 'Sprachüberschreibung angewendet.', + 'locale_override_cleared' => 'Sprachüberschreibung gelöscht.', + 'user_preference_saved' => 'Spracheinstellung gespeichert.', + 'user_preference_cleared' => 'Spracheinstellung gelöscht.', + 'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert', + 'workspace_settings_unchanged' => 'Keine Einstellungsänderungen zu speichern', + 'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurückgesetzt', + 'setting_already_default' => 'Einstellung verwendet bereits den Standard', + ], + 'validation' => [ + 'unsupported_locale' => 'Wählen Sie eine unterstützte Sprache.', + ], +]; diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php new file mode 100644 index 00000000..8d0869b9 --- /dev/null +++ b/apps/platform/lang/en/localization.php @@ -0,0 +1,230 @@ + [ + 'en' => 'English', + 'de' => 'German', + ], + 'source' => [ + 'explicit_override' => 'session override', + 'user_preference' => 'personal preference', + 'workspace_default' => 'workspace default', + 'workspace_override' => 'workspace override', + 'system_default' => 'system default', + ], + 'shell' => [ + 'language' => 'Language', + 'current_language' => 'Current language', + 'language_source' => 'Source: :source', + 'temporary_override' => 'Temporary override', + 'switch_language' => 'Switch language', + 'clear_override' => 'Use inherited language', + 'personal_preference' => 'Personal preference', + 'save_preference' => 'Save preference', + 'inherit_workspace' => 'Use workspace default', + 'workspace' => 'Workspace', + 'choose_workspace' => 'Choose workspace', + 'switch_workspace' => 'Switch workspace', + 'workspace_home' => 'Workspace Home', + 'tenant_scope' => 'Tenant scope', + 'select_tenant' => 'Select tenant', + 'selected_tenant' => 'Selected tenant', + 'no_tenant_selected' => 'No tenant selected', + 'switch_tenant' => 'Switch tenant', + 'clear_tenant_scope' => 'Clear tenant scope', + 'context_unavailable' => 'Context unavailable', + 'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.', + 'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.', + 'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.', + 'view_managed_tenants' => 'View managed tenants', + 'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.', + 'search_tenants' => 'Search tenants...', + 'choose_workspace_first' => 'Choose a workspace first.', + ], + 'workspace' => [ + 'title' => 'Workspace settings', + 'save' => 'Save', + 'reset' => 'Reset', + 'no_manage_permission' => 'You do not have permission to manage workspace settings.', + 'no_workspace_override' => 'No workspace override to reset.', + 'last_modified_by' => ':description - Last modified by :user, :time.', + 'section' => 'Localization settings', + 'section_description' => 'Workspace default used by users without a personal language preference.', + 'default_locale_label' => 'Default language', + 'default_locale_placeholder' => 'Unset (uses system default)', + 'default_locale_helper_unset' => 'Unset. Effective language: :locale (:source).', + 'default_locale_helper_set' => 'Effective language: :locale.', + ], + 'auth' => [ + 'microsoft_not_configured' => 'Microsoft sign-in is not configured.', + 'sign_in_microsoft' => 'Sign in with Microsoft', + 'tenant_admin_membership_required' => 'Tenant Admin access requires a tenant membership.', + ], + 'navigation' => [ + 'findings' => 'Findings', + 'settings' => 'Settings', + 'integrations' => 'Integrations', + 'manage_workspaces' => 'Manage workspaces', + 'operations' => 'Operations', + 'audit_log' => 'Audit Log', + 'alerts' => 'Alerts', + 'governance' => 'Governance', + 'monitoring' => 'Monitoring', + 'dashboard' => 'Dashboard', + ], + 'dashboard' => [ + 'tenant_title' => 'Tenant dashboard', + 'system_title' => 'System dashboard', + 'request_support' => 'Request support', + 'support_request_heading' => 'Request support', + 'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.', + 'submit_request' => 'Submit request', + 'included_context' => 'Included context', + 'severity' => 'Severity', + 'summary' => 'Summary', + 'reproduction_notes' => 'Reproduction notes', + 'contact_name' => 'Contact name', + 'contact_email' => 'Contact email', + 'support_request_submitted' => 'Support request submitted', + 'open_support_diagnostics' => 'Open support diagnostics', + 'support_diagnostics' => 'Support diagnostics', + 'support_diagnostics_description' => 'Redacted tenant context from existing records.', + 'close' => 'Close', + 'time_window' => 'Time window', + 'window' => 'Window', + 'enter_break_glass' => 'Enter break-glass mode', + 'exit_break_glass' => 'Exit break-glass', + 'recovery_mode_enabled' => 'Recovery mode enabled', + 'recovery_mode_ended' => 'Recovery mode ended', + ], + 'review' => [ + 'reporting' => 'Reporting', + 'customer_reviews' => 'Customer reviews', + 'customer_review_workspace' => 'Customer Review Workspace', + 'customer_safe_review_workspace' => 'Customer-safe review workspace', + 'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.', + 'customer_workspace_canonical_note' => 'Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.', + 'reviews' => 'Reviews', + 'clear_filters' => 'Clear filters', + 'tenant' => 'Tenant', + 'latest_review' => 'Latest review', + 'key_findings' => 'Key findings', + 'accepted_risks' => 'Accepted risks', + 'published' => 'Published', + 'review_pack' => 'Review pack', + 'open_latest_review' => 'Open latest review', + 'download_review_pack' => 'Download review pack', + 'no_entitled_tenants' => 'No entitled tenants match this view', + 'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.', + 'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.', + 'no_published_review' => 'No published review', + 'no_published_review_available' => 'No published review available yet', + 'no_findings_recorded' => 'No findings recorded in the published review.', + 'findings_count_summary' => ':count findings summarized in the published review.', + 'findings_count_with_outcomes' => ':count findings. Terminal outcomes: :outcomes.', + 'no_accepted_risks_recorded' => 'No accepted risks recorded.', + 'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).', + 'accepted_risks_governed' => ':count accepted risks are governed.', + 'accepted_risks_on_record' => ':count accepted risks are on record.', + 'unavailable' => 'Unavailable', + 'available' => 'Available', + 'outcome_summary' => 'Outcome summary', + 'review' => 'Review', + 'review_date' => 'Review date', + 'completeness' => 'Completeness', + 'evidence_snapshot' => 'Evidence snapshot', + 'current_export' => 'Current export', + 'executive_posture' => 'Executive posture', + 'sections' => 'Sections', + 'details' => 'Details', + 'export_executive_pack' => 'Export executive pack', + 'outcome' => 'Outcome', + 'export' => 'Export', + 'next_step' => 'Next step', + 'no_tenant_reviews_yet' => 'No tenant reviews yet', + 'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.', + 'create_first_review' => 'Create first review', + 'create_review' => 'Create review', + 'evidence_basis' => 'Evidence basis', + 'evidence_basis_helper' => 'Choose the anchored evidence snapshot for this review.', + 'unable_create_missing_context' => 'Unable to create review - missing context.', + 'select_valid_evidence_snapshot' => 'Select a valid evidence snapshot.', + 'unable_create_review' => 'Unable to create review', + 'review_already_available' => 'Review already available', + 'review_already_available_body' => 'A matching mutable review already exists for this evidence basis.', + 'view_review' => 'View review', + 'open_operation' => 'Open operation', + 'review_composing_background' => 'The review is being composed in the background.', + 'unable_export_missing_context' => 'Unable to export review - missing context.', + 'export_already_queued_body' => 'An executive pack export is already queued or running for this review.', + 'executive_pack_export_unavailable' => 'Executive pack export unavailable', + 'unable_export_executive_pack' => 'Unable to export executive pack', + 'executive_pack_already_available' => 'Executive pack already available', + 'executive_pack_already_available_body' => 'A matching executive pack already exists for this review.', + 'view_pack' => 'View pack', + 'executive_pack_generating_background' => 'The executive pack is being generated in the background.', + 'review_explanation' => 'Review explanation', + 'reason_owner' => 'Reason owner', + 'platform_core' => 'Platform core', + 'platform_reason_family' => 'Platform reason family', + 'compatibility' => 'Compatibility', + 'highlights' => 'Highlights', + 'next_actions' => 'Next actions', + 'related_context' => 'Related context', + 'publication_readiness' => 'Publication readiness', + 'ready_for_publication' => 'This review is ready for publication and executive-pack export.', + 'internal_only' => 'This review is currently safe for internal use only.', + 'needs_follow_up' => 'This review still needs follow-up before publication.', + 'key_entries' => 'Key entries', + 'entry' => 'Entry', + 'follow_up' => 'Follow-up', + 'diagnostics' => 'Diagnostics', + 'result_meaning' => 'Result meaning', + 'result_trust' => 'Result trust', + 'artifact_truth' => 'Artifact truth', + 'no_action_needed' => 'No action needed', + 'count' => 'Count', + 'guidance' => 'Guidance', + 'findings' => 'Findings', + 'reports' => 'Reports', + 'operations' => 'Operations', + 'pending_verification' => 'Pending verification', + 'verified_cleared' => 'Verified cleared', + 'terminal_outcomes' => 'Terminal outcomes', + 'pending' => 'Pending', + 'operation' => 'Operation', + 'operation_description' => 'Inspect the latest review composition or refresh run.', + 'executive_pack' => 'Executive pack', + 'view_executive_pack' => 'View executive pack', + 'executive_pack_description' => 'Open the current export that belongs to this review.', + 'customer_workspace' => 'Customer workspace', + 'open_customer_workspace' => 'Open customer workspace', + 'customer_workspace_description' => 'Open the customer-safe review workspace prefiltered to this tenant.', + 'view_evidence_snapshot' => 'View evidence snapshot', + 'evidence_snapshot_description' => 'Return to the evidence basis behind this review.', + ], + 'findings' => [ + 'all' => 'All', + 'needs_action' => 'Needs action', + 'overdue' => 'Overdue', + 'risk_accepted' => 'Risk accepted', + 'resolved' => 'Resolved', + 'actions' => 'Actions', + 'open_approval_queue' => 'Open approval queue', + ], + 'notifications' => [ + 'locale_override_saved' => 'Language override applied.', + 'locale_override_cleared' => 'Language override cleared.', + 'user_preference_saved' => 'Language preference saved.', + 'user_preference_cleared' => 'Language preference cleared.', + 'workspace_settings_saved' => 'Workspace settings saved', + 'workspace_settings_unchanged' => 'No settings changes to save', + 'workspace_setting_reset' => 'Workspace setting reset to default', + 'setting_already_default' => 'Setting already uses default', + ], + 'validation' => [ + 'unsupported_locale' => 'Choose a supported language.', + ], +]; diff --git a/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php b/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php index 17228e48..db80eafe 100644 --- a/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php @@ -37,7 +37,7 @@ $compressedOutcome['primaryLabel'] ?? null, $state['primaryLabel'] ?? null, $operatorExplanation['headline'] ?? null, - 'Artifact truth', + __('localization.review.artifact_truth'), ]); $primaryReason = $firstArtifactTruthText([ $compressedOutcome['primaryReason'] ?? null, @@ -49,7 +49,7 @@ $compressedOutcome['nextActionText'] ?? null, data_get($operatorExplanation, 'nextAction.text'), $state['nextActionLabel'] ?? null, - 'No action needed', + __('localization.review.no_action_needed'), ]); $diagnosticsSummary = $firstArtifactTruthText([ $compressedOutcome['diagnosticsSummary'] ?? null, @@ -81,7 +81,7 @@ if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') { $summaryFacts->push([ - 'label' => 'Result meaning', + 'label' => __('localization.review.result_meaning'), 'value' => $evaluationSpec->label, 'badge' => BadgeCatalog::summaryData($evaluationSpec), ]); @@ -89,7 +89,7 @@ if ($trustSpec && $trustSpec->label !== 'Unknown') { $summaryFacts->push([ - 'label' => 'Result trust', + 'label' => __('localization.review.result_trust'), 'value' => $trustSpec->label, 'badge' => BadgeCatalog::summaryData($trustSpec), ]); @@ -133,7 +133,7 @@
- Diagnostics + {{ __('localization.review.diagnostics') }}
@@ -164,7 +164,7 @@
- {{ $count['label'] ?? 'Count' }} + {{ $count['label'] ?? __('localization.review.count') }}
{{ (int) ($count['value'] ?? 0) }} @@ -211,7 +211,7 @@
-
Next step
+
{{ __('localization.review.next_step') }}
{{ $nextActionText }}
@@ -237,7 +237,7 @@ @if ($nextSteps !== [])
-
Guidance
+
{{ __('localization.review.guidance') }}
    @foreach ($nextSteps as $step) @continue(! is_string($step) || trim($step) === '') diff --git a/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php b/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php index 5559a0b5..24079816 100644 --- a/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php @@ -42,14 +42,14 @@ @if ($entries !== [])
    -
    Key entries
    +
    {{ __('localization.review.key_entries') }}
    @foreach ($entries as $entry) @continue(! is_array($entry))
    - {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }} + {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
    @php @@ -82,7 +82,7 @@ @if ($nextActions !== [])
    -
    Follow-up
    +
    {{ __('localization.review.follow_up') }}
      @foreach ($nextActions as $action) @continue(! is_string($action) || trim($action) === '') diff --git a/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php b/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php index 7a46d57d..ff3a1169 100644 --- a/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php @@ -25,7 +25,7 @@ @if ($operatorExplanation !== [])
      - {{ $operatorExplanation['headline'] ?? 'Review explanation' }} + {{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }}
      @if (filled($operatorExplanation['reliabilityStatement'] ?? null)) @@ -45,13 +45,13 @@ @if ($reasonSemantics !== [])
      -
      Reason owner
      -
      {{ $reasonSemantics['owner_label'] ?? 'Platform core' }}
      +
      {{ __('localization.review.reason_owner') }}
      +
      {{ $reasonSemantics['owner_label'] ?? __('localization.review.platform_core') }}
      -
      Platform reason family
      -
      {{ $reasonSemantics['family_label'] ?? 'Compatibility' }}
      +
      {{ __('localization.review.platform_reason_family') }}
      +
      {{ $reasonSemantics['family_label'] ?? __('localization.review.compatibility') }}
      @endif @@ -74,7 +74,7 @@ @if ($highlights !== [])
      -
      Highlights
      +
      {{ __('localization.review.highlights') }}
        @foreach ($highlights as $highlight) @continue(! is_string($highlight) || trim($highlight) === '') @@ -87,7 +87,7 @@ @if ($nextActions !== [])
        -
        Next actions
        +
        {{ __('localization.review.next_actions') }}
          @foreach ($nextActions as $action) @continue(! is_string($action) || trim($action) === '') @@ -100,7 +100,7 @@ @if ($contextLinks !== [])
          -
          Related context
          +
          {{ __('localization.review.related_context') }}
          @foreach ($contextLinks as $link) @php @@ -130,11 +130,11 @@ @endif
          -
          Publication readiness
          +
          {{ __('localization.review.publication_readiness') }}
          @if ($publishBlockers === [] && $decisionDirection === 'publishable')
          - This review is ready for publication and executive-pack export. + {{ __('localization.review.ready_for_publication') }}
          @elseif ($publishBlockers !== [])
            @@ -146,7 +146,7 @@
          @elseif ($decisionDirection === 'internal_only')
          -
          This review is currently safe for internal use only.
          +
          {{ __('localization.review.internal_only') }}
          @if ($publicationNextAction !== null)
          {{ $publicationNextAction }}
          @@ -154,7 +154,7 @@
          @else
          - {{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }} + {{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
          @endif
          diff --git a/apps/platform/resources/views/filament/pages/auth/login.blade.php b/apps/platform/resources/views/filament/pages/auth/login.blade.php index 777da10c..a2c3d4fe 100644 --- a/apps/platform/resources/views/filament/pages/auth/login.blade.php +++ b/apps/platform/resources/views/filament/pages/auth/login.blade.php @@ -14,7 +14,7 @@ @if (! $isConfigured)
          - Microsoft sign-in is not configured. + {{ __('localization.auth.microsoft_not_configured') }}
          @endif @@ -25,11 +25,11 @@ :disabled="! $isConfigured" color="primary" > - Sign in with Microsoft + {{ __('localization.auth.sign_in_microsoft') }}
          - Tenant Admin access requires a tenant membership. + {{ __('localization.auth.tenant_admin_membership_required') }}
          diff --git a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php new file mode 100644 index 00000000..6280e61e --- /dev/null +++ b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php @@ -0,0 +1,164 @@ + + @php + $scope = $this->appliedScope(); + $sections = $this->sections(); + $emptyState = $this->calmEmptyState(); + @endphp + + +
          +
          + + Governance inbox +
          + +
          +

          + Governance inbox +

          + +

          + This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. +

          +
          + +
          + @if (filled($scope['workspace_label'] ?? null)) + + Workspace: {{ $scope['workspace_label'] }} + + @endif + + + Scope: {{ $scope['family_label'] ?? 'All attention' }} + + + + Visible items: {{ $scope['total_count'] ?? 0 }} + + + @if (filled($scope['tenant_label'] ?? null)) + + Tenant: {{ $scope['tenant_label'] }} + + @endif +
          + +
          + + All attention + {{ $scope['total_count'] ?? 0 }} + + + @foreach ($this->availableFamilies() as $family) + + {{ $family['label'] }} + {{ $family['count'] }} + + @endforeach +
          + + @if ($this->hasTenantPrefilter()) +
          + The inbox is currently filtered to one tenant. + + + Clear tenant filter + +
          + @endif +
          +
          + + @if ($sections === []) + +
          +
          +

          {{ $emptyState['title'] }}

          +

          {{ $emptyState['body'] }}

          +
          + + @if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null)) +
          + + {{ $emptyState['action_label'] }} + +
          + @endif +
          +
          + @else + @foreach ($sections as $section) + +
          +
          +
          +
          +

          {{ $section['label'] }}

          + + {{ $section['count'] }} + +
          + +

          {{ $section['summary'] }}

          +
          + +
          + + {{ $section['dominant_action_label'] }} + +
          +
          + + @if ($section['count'] === 0) +
          + {{ $section['empty_state'] }} +
          + @else +
            + @foreach ($section['entries'] as $entry) +
          • +
            +
            + @if (filled($entry['tenant_label'] ?? null)) +
            + {{ $entry['tenant_label'] }} +
            + @endif + +
            + + {{ $entry['headline'] }} + + + + {{ $entry['status_label'] }} + +
            + + @if (filled($entry['subline'] ?? null)) +

            {{ $entry['subline'] }}

            + @endif +
            + +
            + + Open source + +
            +
            +
          • + @endforeach +
          + @endif +
          +
          + @endforeach + @endif +
          \ No newline at end of file diff --git a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php index b9a5f6b2..81154556 100644 --- a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php +++ b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php @@ -2,18 +2,18 @@
          - Customer-safe review workspace + {{ __('localization.review.customer_safe_review_workspace') }}
          - Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context. + {{ __('localization.review.customer_workspace_intro') }}
          - Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces. + {{ __('localization.review.customer_workspace_canonical_note') }}
          {{ $this->table }} - \ No newline at end of file + diff --git a/apps/platform/resources/views/filament/partials/context-bar.blade.php b/apps/platform/resources/views/filament/partials/context-bar.blade.php index 28f3e44f..2216dafb 100644 --- a/apps/platform/resources/views/filament/partials/context-bar.blade.php +++ b/apps/platform/resources/views/filament/partials/context-bar.blade.php @@ -31,8 +31,8 @@ @endphp @php - $tenantLabel = $currentTenantName ?? 'No tenant selected'; - $workspaceLabel = $workspace?->name ?? 'Choose workspace'; + $tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected'); + $workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace'); $hasActiveTenant = $currentTenantName !== null; $managedTenantsUrl = $workspace ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) @@ -40,7 +40,8 @@ $workspaceUrl = $workspace ? route('admin.home') : ChooseWorkspace::getUrl(panel: 'admin'); - $tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace'; + $tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace'); + $localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin'; @endphp
          @@ -63,7 +64,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t @endif @@ -154,23 +155,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark: @else @if ($tenants->isEmpty())
          -
          No active tenants are available for the standard operating context in this workspace.
          +
          {{ __('localization.shell.no_active_tenants') }}
          - View managed tenants + {{ __('localization.shell.view_managed_tenants') }}
          @else @if (! $hasActiveTenant)
          - No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context. + {{ __('localization.shell.workspace_wide_available') }}
          @endif @@ -207,7 +208,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{ @csrf @endif @@ -216,10 +217,12 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
          @else
          - Choose a workspace first. + {{ __('localization.shell.choose_workspace_first') }}
          @endif
        + + @include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
      diff --git a/apps/platform/resources/views/filament/partials/locale-switcher.blade.php b/apps/platform/resources/views/filament/partials/locale-switcher.blade.php new file mode 100644 index 00000000..37ba9f92 --- /dev/null +++ b/apps/platform/resources/views/filament/partials/locale-switcher.blade.php @@ -0,0 +1,110 @@ +@php + use App\Models\User; + use App\Services\Localization\LocaleResolver; + + $plane = $plane ?? 'admin'; + $showPreference = (bool) ($showPreference ?? true); + $embedded = (bool) ($embedded ?? false); + + /** @var LocaleResolver $localeResolver */ + $localeResolver = app(LocaleResolver::class); + $localeContext = request()->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE); + $localeContext = is_array($localeContext) ? $localeContext : $localeResolver->resolve(request(), $plane); + $localeOptions = LocaleResolver::localeOptions(); + $currentLocale = (string) ($localeContext['locale'] ?? 'en'); + $source = (string) ($localeContext['source'] ?? LocaleResolver::SOURCE_SYSTEM_DEFAULT); + $sourceLabel = __('localization.source.'.$source); + $user = auth()->user(); + $preferredLocale = $user instanceof User ? $user->preferred_locale : null; +@endphp + +
      + + + + + + +
      +
      +
      + {{ __('localization.shell.current_language') }} +
      +
      +
      + {{ $localeOptions[$currentLocale] ?? strtoupper($currentLocale) }} +
      +
      + {{ __('localization.shell.language_source', ['source' => $sourceLabel]) }} +
      +
      +
      + +
      + +
      + @csrf + + + + @foreach ($localeOptions as $locale => $label) + + @endforeach + + + +
      + + @if ($source === LocaleResolver::SOURCE_EXPLICIT_OVERRIDE) +
      + @csrf + @method('DELETE') + +
      + @endif + + @if ($showPreference && $user instanceof User) +
      + +
      + @csrf + + + + + @foreach ($localeOptions as $locale => $label) + + @endforeach + + + +
      + @endif +
      +
      +
      +
      diff --git a/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php b/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php index 7f3baa8e..36891f0b 100644 --- a/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php +++ b/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php @@ -1,9 +1,18 @@ @php + use App\Support\Badges\BadgeCatalog; + use App\Support\Badges\BadgeDomain; + /** @var \App\Models\Workspace $workspace */ $workspace = $this->workspace; $customerHealthDecision = $this->customerHealthDecision(); $tenants = $this->workspaceTenants(); $runs = $this->recentRuns(); + $commercialLifecycle = $this->workspaceCommercialLifecycleSummary(); + $commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null); + $commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : []; + $activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null; + $reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null; + $readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null; $workspaceEntitlementSummary = $this->workspaceEntitlementSummary(); $planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null; $entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? []; @@ -40,6 +49,63 @@ @include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision]) @endif + + + Commercial lifecycle + + +
      +
      +

      Current state

      +
      + + {{ $commercialBadge->label }} + + {{ $commercialLifecycle['source_label'] ?? 'default active paid' }} +
      +

      {{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}

      +
      + +
      +

      Lifecycle rationale

      +

      {{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}

      +

      + {{ $commercialLifecycle['last_changed_by'] ?? 'System default' }} + @if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface) + · {{ $commercialLifecycle['last_changed_at']->diffForHumans() }} + @endif +

      +
      +
      + +
      + @foreach ([ + 'Managed tenant activation' => $activationLifecycleDecision, + 'Review-pack starts' => $reviewPackLifecycleDecision, + 'Read-only history and downloads' => $readOnlyLifecycleDecision, + ] as $label => $decision) + @if (is_array($decision)) +
      +
      +
      +

      {{ $label }}

      +

      {{ $decision['message'] ?? 'No lifecycle decision message available.' }}

      +
      + + {{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }} + +
      +
      + @endif + @endforeach +
      +
      + @if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision)) diff --git a/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php b/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php index 12e6d34a..37c6536f 100644 --- a/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php +++ b/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php @@ -1,13 +1,3 @@ -@php - $findingsLastRun = $this->findingsLastRun(); - $findingsLastRunStatusSpec = $findingsLastRun - ? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status) - : null; - $findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed' - ? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome) - : null; -@endphp -
      @@ -17,7 +7,7 @@

      Operator warning

      - Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected. + Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.

      @@ -25,100 +15,17 @@ - Rebuild Findings Lifecycle + No supported runbooks - Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings. + Supported platform runbooks will appear here when they are part of current product truth. - - - {{ $this->findingsScopeLabel() }} - - - -
      - @if ($findingsLastRun) -
      - Last run - - - {{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }} - - - @if ($findingsLastRunStatusSpec) - - {{ $findingsLastRunStatusSpec->label }} - - @endif - - @if ($findingsLastRunOutcomeSpec) - - {{ $findingsLastRunOutcomeSpec->label }} - - @endif - - @if ($findingsLastRun->initiator_name) - - by {{ $findingsLastRun->initiator_name }} - - @endif -
      - @endif - - @if (is_array($this->findingsPreflight)) -
      - -
      -

      Affected

      -

      - {{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }} -

      -
      -
      - - -
      -

      Total scanned

      -

      - {{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }} -

      -
      -
      - - -
      -

      Estimated tenants

      -

      - {{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }} -

      -
      -
      -
      - - @if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) -
      - - Nothing to do for the current scope. -
      - @endif - @else -
      - - Run Preflight to see how many findings would change for the selected scope. -
      - @endif +
      + + There are no operator-run repair runbooks exposed on this surface.
      -
      diff --git a/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php b/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php index f7a0ffda..8aa2d392 100644 --- a/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php +++ b/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php @@ -11,6 +11,7 @@ /** @var bool $canManage */ /** @var bool $generationBlocked */ /** @var ?string $generationBlockReason */ + /** @var ?string $generationWarningReason */ /** @var ?string $customerWorkspaceUrl */ /** @var ?string $downloadUrl */ /** @var ?string $failedReason */ @@ -33,6 +34,12 @@
      @endif + @if ($canManage && ! $generationBlocked && $generationWarningReason) +
      + {{ $generationWarningReason }} +
      + @endif + @if (! $pack) {{-- State 1: No pack --}}
      diff --git a/apps/platform/routes/web.php b/apps/platform/routes/web.php index 394db8a8..0435c31a 100644 --- a/apps/platform/routes/web.php +++ b/apps/platform/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\ClearTenantContextController; +use App\Http\Controllers\LocalizationController; use App\Http\Controllers\OpenFindingExceptionsQueueController; use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\ReviewPackDownloadController; @@ -67,6 +68,21 @@ ->middleware('throttle:entra-callback') ->name('auth.entra.callback'); +Route::middleware(['web'])->group(function (): void { + Route::get('/localization/context', [LocalizationController::class, 'context']) + ->name('localization.context'); + + Route::post('/localization/override', [LocalizationController::class, 'updateOverride']) + ->name('localization.override.update'); + + Route::delete('/localization/override', [LocalizationController::class, 'clearOverride']) + ->name('localization.override.clear'); +}); + +Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) + ->post('/users/me/locale-preference', [LocalizationController::class, 'updateUserPreference']) + ->name('localization.preference.update'); + $makeSmokeCookie = static fn () => cookie()->make( SuppressDebugbarForSmokeRequests::COOKIE_NAME, SuppressDebugbarForSmokeRequests::COOKIE_VALUE, diff --git a/apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php b/apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php new file mode 100644 index 00000000..84301c55 --- /dev/null +++ b/apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php @@ -0,0 +1,20 @@ +not->toContain('tenantpilot:findings:backfill-lifecycle') + ->not->toContain('tenantpilot:run-deploy-runbooks'); + + expect((string) file_get_contents(base_path('routes/console.php'))) + ->not->toContain('tenantpilot:findings:backfill-lifecycle') + ->not->toContain('tenantpilot:run-deploy-runbooks'); +}); diff --git a/apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php b/apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php deleted file mode 100644 index f6cc1ab1..00000000 --- a/apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php +++ /dev/null @@ -1,31 +0,0 @@ -create(); - - $this->mock(FindingsLifecycleBackfillRunbookService::class, function ($mock) use ($run): void { - $mock->shouldReceive('start') - ->once() - ->withArgs(function ($scope, $initiator, $reason, $source): bool { - return $scope instanceof FindingsLifecycleBackfillScope - && $scope->isAllTenants() - && $initiator === null - && $reason instanceof RunbookReason - && $source === 'deploy_hook'; - }) - ->andReturn($run); - }); - - $this->artisan('tenantpilot:run-deploy-runbooks') - ->assertExitCode(0); -}); diff --git a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php index 5dcbe0da..38b69706 100644 --- a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php +++ b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php @@ -10,10 +10,14 @@ use App\Models\EvidenceSnapshotItem; use App\Models\Finding; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\StoredReport; use App\Models\Tenant; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; +use App\Services\Settings\SettingsWriter; use App\Support\Auth\Capabilities; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; @@ -68,6 +72,23 @@ function evidenceSnapshotHeaderActions(Testable $component): array return $instance->getCachedHeaderActions(); } +function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Evidence read-only preservation test', + ); +} + it('renders the evidence list page for an authorized user', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); @@ -207,6 +228,36 @@ function evidenceSnapshotHeaderActions(Testable $component): array ->toContain('operation_run', 'review_pack'); }); +it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $snapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 2], + 'generated_at' => now(), + ]); + + suspendEvidenceSnapshotWorkspace($tenant); + + $this->actingAs($user) + ->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant')) + ->assertOk(); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]) + ->assertActionVisible('refresh_evidence') + ->assertActionDisabled('refresh_evidence') + ->assertActionVisible('expire_snapshot') + ->assertActionDisabled('expire_snapshot'); +}); + it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php b/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php new file mode 100644 index 00000000..5526d811 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php @@ -0,0 +1,16 @@ +toBe('Tenant-Dashboard') + ->and(FindingResource::getNavigationGroup())->toBe('Governance') + ->and(__('localization.findings.needs_action'))->toBe('Handlungsbedarf') + ->and(__('baseline-compare.stat_total_findings'))->toBe('Findings gesamt') + ->and(__('findings.rbac.detail_heading'))->toBe('Intune-RBAC-Rollendefinitions-Drift'); +}); diff --git a/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php b/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php deleted file mode 100644 index de5815ba..00000000 --- a/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php +++ /dev/null @@ -1,21 +0,0 @@ -actingAs($user); - Filament::setTenant($tenant, true); - - Livewire::test(ListFindings::class) - ->assertActionExists('backfill_lifecycle') - ->assertActionEnabled('backfill_lifecycle'); -}); diff --git a/apps/platform/tests/Feature/Findings/FindingBackfillTest.php b/apps/platform/tests/Feature/Findings/FindingBackfillTest.php deleted file mode 100644 index 24b28f0f..00000000 --- a/apps/platform/tests/Feature/Findings/FindingBackfillTest.php +++ /dev/null @@ -1,136 +0,0 @@ -for($tenant)->create([ - 'severity' => Finding::SEVERITY_MEDIUM, - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), - 'acknowledged_by_user_id' => (int) $user->getKey(), - 'first_seen_at' => null, - 'last_seen_at' => null, - 'times_seen' => null, - 'sla_days' => null, - 'due_at' => null, - 'triaged_at' => null, - 'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'), - 'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'), - ]); - - BackfillFindingLifecycleJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: (int) $user->getKey(), - ); - - $finding->refresh(); - - expect($finding->status)->toBe(Finding::STATUS_TRIAGED) - ->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00') - ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00') - ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00') - ->and($finding->times_seen)->toBe(1) - ->and($finding->sla_days)->toBe(14) - ->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00') - ->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00') - ->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey()); - - CarbonImmutable::setTestNow(); -}); - -it('computes drift recurrence keys and consolidates drift duplicates', function (): void { - CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); - - [$user, $tenant] = createUserWithTenant(role: 'manager'); - - $scopeKey = hash('sha256', 'scope-drift-backfill-duplicate'); - - $evidence = [ - 'change_type' => 'modified', - 'summary' => [ - 'kind' => 'policy_snapshot', - 'changed_fields' => ['snapshot_hash'], - ], - 'baseline' => ['policy_id' => 'policy-dupe'], - 'current' => ['policy_id' => 'policy-dupe'], - ]; - - $open = Finding::factory()->for($tenant)->create([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'subject_type' => 'policy', - 'subject_external_id' => 'policy-dupe', - 'status' => Finding::STATUS_NEW, - 'recurrence_key' => null, - 'evidence_jsonb' => $evidence, - 'first_seen_at' => null, - 'last_seen_at' => null, - 'times_seen' => null, - 'sla_days' => null, - 'due_at' => null, - 'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), - 'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), - ]); - - $duplicate = Finding::factory()->for($tenant)->create([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'subject_type' => 'policy', - 'subject_external_id' => 'policy-dupe', - 'status' => Finding::STATUS_RESOLVED, - 'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), - 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, - 'recurrence_key' => null, - 'evidence_jsonb' => $evidence, - 'first_seen_at' => null, - 'last_seen_at' => null, - 'times_seen' => null, - 'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'), - 'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'), - ]); - - BackfillFindingLifecycleJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: (int) $user->getKey(), - ); - - $tenantId = (int) $tenant->getKey(); - $expectedRecurrenceKey = hash( - 'sha256', - sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'), - ); - - expect(Finding::query() - ->where('tenant_id', $tenantId) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('recurrence_key', $expectedRecurrenceKey) - ->count())->toBe(1); - - $open->refresh(); - $duplicate->refresh(); - - expect($open->recurrence_key)->toBe($expectedRecurrenceKey) - ->and($open->status)->toBe(Finding::STATUS_NEW); - - expect($duplicate->recurrence_key)->toBeNull() - ->and($duplicate->status)->toBe(Finding::STATUS_CLOSED) - ->and($duplicate->resolved_reason)->toBeNull() - ->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(); -}); diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php new file mode 100644 index 00000000..e55e51f0 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php @@ -0,0 +1,61 @@ +create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $service = app(FindingWorkflowService::class); + $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW, [ + 'owner_user_id' => null, + 'assignee_user_id' => null, + 'sla_days' => 14, + 'due_at' => now()->addDays(14), + ]); + + $triaged = $service->triage($finding, $tenant, $owner); + $assigned = $service->assign( + finding: $triaged, + tenant: $tenant, + actor: $owner, + assigneeUserId: (int) $assignee->getKey(), + ownerUserId: (int) $owner->getKey(), + ); + $inProgress = $service->startProgress($assigned, $tenant, $owner); + $resolved = $service->resolve($inProgress, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED); + $riskAccepted = $service->riskAccept( + $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), + $tenant, + $owner, + Finding::CLOSE_REASON_ACCEPTED_RISK, + ); + + expect($triaged->status)->toBe(Finding::STATUS_TRIAGED) + ->and($triaged->triaged_at)->not->toBeNull() + ->and((int) $assigned->owner_user_id)->toBe((int) $owner->getKey()) + ->and((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey()) + ->and($assigned->sla_days)->toBe(14) + ->and($assigned->due_at)->not->toBeNull() + ->and($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS) + ->and($inProgress->in_progress_at)->not->toBeNull() + ->and($resolved->status)->toBe(Finding::STATUS_RESOLVED) + ->and($resolved->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED) + ->and($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED) + ->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK); + + expect($this->latestFindingAudit($triaged, AuditActionId::FindingTriaged))->not->toBeNull() + ->and($this->latestFindingAudit($assigned, AuditActionId::FindingAssigned))->not->toBeNull() + ->and($this->latestFindingAudit($inProgress, AuditActionId::FindingInProgress))->not->toBeNull() + ->and($this->latestFindingAudit($resolved, AuditActionId::FindingResolved))->not->toBeNull() + ->and($this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted))->not->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php b/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php deleted file mode 100644 index 89545345..00000000 --- a/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php +++ /dev/null @@ -1,101 +0,0 @@ -create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - OperationalControlActivation::factory()->workspaceScoped()->create([ - 'control_key' => 'findings.lifecycle.backfill', - 'workspace_id' => (int) $tenant->workspace_id, - 'reason_text' => 'Workspace-specific pause.', - ]); - - $this->actingAs($user); - Filament::setTenant($tenant, true); - - Livewire::test(ListFindings::class) - ->assertActionExists('backfill_lifecycle') - ->assertActionEnabled('backfill_lifecycle') - ->callAction('backfill_lifecycle') - ->assertNotified('Findings lifecycle backfill paused'); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0); - - $audit = AuditLog::query() - ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull() - ->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id) - ->and($audit?->tenant_id)->toBe((int) $tenant->getKey()) - ->and($audit?->status)->toBe('blocked') - ->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill') - ->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id); -}); - -it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void { - Queue::fake(); - - [$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner'); - [$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner'); - - Finding::factory()->create([ - 'tenant_id' => (int) $allowedTenant->getKey(), - 'due_at' => null, - ]); - - OperationalControlActivation::factory()->workspaceScoped()->create([ - 'control_key' => 'findings.lifecycle.backfill', - 'workspace_id' => (int) $blockedTenant->workspace_id, - 'reason_text' => 'Paused only for the blocked workspace.', - ]); - - $this->actingAs($allowedUser); - Filament::setTenant($allowedTenant, true); - - Livewire::test(ListFindings::class) - ->assertActionExists('backfill_lifecycle') - ->assertActionEnabled('backfill_lifecycle') - ->callAction('backfill_lifecycle'); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->where('tenant_id', (int) $allowedTenant->getKey()) - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - - Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool { - return $job->tenantId === (int) $allowedTenant->getKey() - && $job->workspaceId === (int) $allowedTenant->workspace_id; - }); - - expect(AuditLog::query() - ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) - ->where('tenant_id', (int) $allowedTenant->getKey()) - ->exists())->toBeFalse(); -}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php b/apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php new file mode 100644 index 00000000..72e3a713 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php @@ -0,0 +1,33 @@ +for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListFindings::class) + ->assertActionDoesNotExist('backfill_lifecycle') + ->assertActionExists('triage_all_matching') + ->assertTableActionVisible('triage', $finding) + ->assertTableActionVisible('assign', $finding) + ->assertTableActionVisible('resolve', $finding) + ->assertTableActionVisible('request_exception', $finding); + + expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->exists())->toBeFalse(); +}); diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php new file mode 100644 index 00000000..2b83361f --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php @@ -0,0 +1,99 @@ +create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertRedirect('/admin/choose-workspace'); +}); + +it('returns 404 for users outside the active workspace on the governance inbox route', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) Workspace::factory()->create()->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + mock(WorkspaceCapabilityResolver::class, function ($mock): void { + $mock->shouldReceive('isMember')->andReturnTrue(); + $mock->shouldReceive('can')->andReturnFalse(); + }); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Governance inbox'); +}); + +it('returns 404 for explicit tenant filters outside the actor scope', function (): void { + $visibleTenant = Tenant::factory()->create(['status' => 'active']); + [$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly'); + + $hiddenTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $visibleTenant->workspace_id, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey()) + ->assertNotFound(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php new file mode 100644 index 00000000..506e626e --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php @@ -0,0 +1,64 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + $finding = Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create(); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + $context = new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + backLinkLabel: 'Back to governance inbox', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'), + ); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')); + + $response->assertOk(); + + $expectedMyFindingsUrl = htmlspecialchars( + MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()), + ENT_QUOTES, + ); + $expectedOperationUrl = htmlspecialchars( + OperationRunLinks::tenantlessView($run, $context), + ENT_QUOTES, + ); + + $response->assertSee($expectedMyFindingsUrl, false) + ->assertSee($expectedOperationUrl, false) + ->assertSee((string) $finding->getKey()) + ->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php new file mode 100644 index 00000000..4f3a33a0 --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php @@ -0,0 +1,143 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner'); + + $bravoTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $bravoTenant->getKey() => ['role' => 'owner'], + ]); + + Finding::factory() + ->for($alphaTenant) + ->assignedTo((int) $user->getKey()) + ->ownedBy((int) $user->getKey()) + ->overdueByHours() + ->create(); + + Finding::factory() + ->for($bravoTenant) + ->reopened() + ->create(); + + OperationRun::factory() + ->forTenant($alphaTenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'status' => AlertDelivery::STATUS_FAILED, + 'payload' => [ + 'title' => 'Delivery failed', + 'body' => 'A notification destination failed.', + ], + ]); + + $backupHealthResolver = app(TenantBackupHealthResolver::class); + $fingerprints = app(TenantTriageReviewFingerprint::class); + $alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant)); + + expect($alphaBackupFingerprint)->not->toBeNull(); + + TenantTriageReview::factory() + ->for($alphaTenant) + ->followUpNeeded() + ->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => $alphaBackupFingerprint['fingerprint'], + 'review_snapshot' => $alphaBackupFingerprint['snapshot'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Assigned findings') + ->assertSee('Findings intake') + ->assertSee('Operations follow-up') + ->assertSee('Alert delivery failures') + ->assertSee('Review follow-up') + ->assertSee('Open my findings') + ->assertSee('Open terminal follow-up') + ->assertSee('Open alert deliveries') + ->assertSee('Open review follow-up'); +}); + +it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { + $alphaTenant = Tenant::factory()->create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner'); + + $bravoTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $bravoTenant->getKey() => ['role' => 'owner'], + ]); + + Finding::factory() + ->for($bravoTenant) + ->assignedTo((int) $user->getKey()) + ->create(); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey()) + ->assertOk() + ->assertSee('This tenant filter is hiding other visible attention') + ->assertSee('Clear tenant filter'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures') + ->assertOk() + ->assertSee('Alert delivery failures') + ->assertSee('No failed alert deliveries match this tenant filter right now.') + ->assertDontSee('Open my findings'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php b/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php index 4e4fb5e9..17669df0 100644 --- a/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php +++ b/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php @@ -12,7 +12,6 @@ function livewireTrustedStateFirstSliceFixtures(): array return [ TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php', TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php', - TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php', ]; } diff --git a/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php b/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php new file mode 100644 index 00000000..872a4286 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php @@ -0,0 +1,43 @@ +withSession([LocaleResolver::SESSION_OVERRIDE_KEY => 'de']) + ->get('/admin/login') + ->assertSuccessful() + ->assertSee('Mit Microsoft anmelden') + ->assertSee('Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft'); +}); + +it('keeps system plane resolution independent from user and workspace preferences', function (): void { + [$workspace, $user] = localizationWorkspaceMember(); + + $user->forceFill(['preferred_locale' => 'de'])->save(); + session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => null, + ]) + ->getJson('/localization/context?plane=system') + ->assertSuccessful() + ->assertJsonPath('locale', 'en') + ->assertJsonPath('source', LocaleResolver::SOURCE_SYSTEM_DEFAULT) + ->assertJsonPath('user_preference_locale', null) + ->assertJsonPath('workspace_default_locale', null); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->getJson('/localization/context?plane=system') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE); +}); diff --git a/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php b/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php new file mode 100644 index 00000000..1417cb83 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php @@ -0,0 +1,87 @@ +updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + value: 'de', + ); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.preference.update'), ['preferred_locale' => 'en']) + ->assertRedirect(); + + expect($user->refresh()->preferred_locale)->toBe('en'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'en') + ->assertJsonPath('source', LocaleResolver::SOURCE_USER_PREFERENCE); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.preference.update'), ['preferred_locale' => '']) + ->assertRedirect(); + + expect($user->refresh()->preferred_locale)->toBeNull(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT); +}); + +it('allows temporary overrides to win until cleared', function (): void { + [$workspace, $user] = localizationWorkspaceMember(); + + $user->forceFill(['preferred_locale' => 'en'])->save(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.override.update'), ['locale' => 'de']) + ->assertRedirect(); + + expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBe('de'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->delete(route('localization.override.clear')) + ->assertRedirect(); + + expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php b/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php new file mode 100644 index 00000000..00dd114c --- /dev/null +++ b/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php @@ -0,0 +1,47 @@ +actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->post(route('localization.preference.update'), ['preferred_locale' => 'de']) + ->assertRedirect() + ->assertSessionHas('status', 'Spracheinstellung gespeichert.'); +}); + +it('formats override feedback in the newly effective locale', function (): void { + [$workspace, $user] = localizationWorkspaceMember(); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + value: 'de', + ); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.override.update'), ['locale' => 'de']) + ->assertRedirect() + ->assertSessionHas('status', 'Sprachüberschreibung angewendet.'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'en', + ]) + ->delete(route('localization.override.clear')) + ->assertRedirect() + ->assertSessionHas('status', 'Sprachüberschreibung gelöscht.'); +}); diff --git a/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php b/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php new file mode 100644 index 00000000..15a5cdc5 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php @@ -0,0 +1,31 @@ +updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + value: 'de', + ); + + $audit = AuditLog::query()->latest('id')->first(); + + expect($audit)->not->toBeNull() + ->and($audit->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value) + ->and(data_get($audit->metadata, 'domain'))->toBe(LocaleResolver::SETTING_DOMAIN) + ->and(data_get($audit->metadata, 'key'))->toBe(LocaleResolver::SETTING_DEFAULT_LOCALE) + ->and(data_get($audit->metadata, 'after_value'))->toBe('de'); +}); diff --git a/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php b/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php new file mode 100644 index 00000000..58dc743c --- /dev/null +++ b/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php @@ -0,0 +1,23 @@ + 'English fallback probe'], 'en'); + + App::setFallbackLocale('en'); + App::setLocale('de'); + + expect(__('localization.fallback_probe'))->toBe('English fallback probe'); +}); + +it('does not expose raw translation keys for supported first-wave catalogs', function (): void { + App::setLocale('de'); + + expect(__('localization.auth.sign_in_microsoft'))->not->toBe('localization.auth.sign_in_microsoft') + ->and(__('baseline-compare.button_view_findings'))->not->toBe('baseline-compare.button_view_findings') + ->and(__('findings.rbac.restore_unsupported'))->not->toBe('findings.rbac.restore_unsupported'); +}); diff --git a/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php b/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php new file mode 100644 index 00000000..508572d6 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php @@ -0,0 +1,50 @@ +actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.localization_default_locale', null) + ->set('data.localization_default_locale', 'de') + ->callAction('save') + ->assertHasNoErrors() + ->assertSet('data.localization_default_locale', 'de'); + + expect(app(SettingsResolver::class)->resolveValue($workspace, LocaleResolver::SETTING_DOMAIN, LocaleResolver::SETTING_DEFAULT_LOCALE)) + ->toBe('de'); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', LocaleResolver::SETTING_DOMAIN) + ->where('key', LocaleResolver::SETTING_DEFAULT_LOCALE) + ->exists())->toBeTrue(); +}); + +it('keeps workspace default locale authorization aligned to settings capabilities', function (): void { + [$workspace, $user] = localizationWorkspaceMember('readonly'); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.localization_default_locale', null) + ->assertActionVisible('save') + ->assertActionDisabled('save') + ->call('save') + ->assertStatus(403); +}); diff --git a/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php index fd205d65..cfea8462 100644 --- a/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php +++ b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php @@ -5,13 +5,16 @@ use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; use App\Models\AuditLog; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\TenantOnboardingSession; use App\Models\User; use App\Models\Workspace; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Workspaces\WorkspaceContext; @@ -21,7 +24,12 @@ /** * @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable} */ -function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array +function readyOnboardingEntitlementContext( + int $activeTenantCount = 0, + ?int $limitOverride = null, + ?string $overrideReason = null, + ?string $commercialState = null, +): array { Queue::fake(); @@ -110,6 +118,22 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim } } + if ($commercialState !== null) { + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $workspace, + state: $commercialState, + reason: 'Onboarding entitlement test commercial state', + ); + } + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [ @@ -187,4 +211,66 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim $context['tenant']->refresh(); expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE); -}); \ No newline at end of file +}); + +it('allows onboarding activation while a workspace is in trial', function (): void { + $context = readyOnboardingEntitlementContext( + activeTenantCount: 0, + commercialState: WorkspaceCommercialLifecycleResolver::STATE_TRIAL, + ); + + $context['component'] + ->assertSee('Activation entitlement') + ->assertSee('Trial') + ->call('completeOnboarding'); + + $context['tenant']->refresh(); + + expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE) + ->and(AuditLog::query() + ->where('workspace_id', (int) $context['workspace']->getKey()) + ->where('action', 'managed_tenant_onboarding.activation') + ->exists())->toBeTrue(); +}); + +it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void { + $context = readyOnboardingEntitlementContext( + activeTenantCount: 0, + commercialState: WorkspaceCommercialLifecycleResolver::STATE_GRACE, + ); + + $context['component'] + ->assertSee('Activation entitlement') + ->assertSee('Grace') + ->assertSee('New managed-tenant activation is frozen while this workspace is in grace.') + ->call('completeOnboarding'); + + $context['tenant']->refresh(); + + expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING) + ->and(AuditLog::query() + ->where('workspace_id', (int) $context['workspace']->getKey()) + ->where('action', 'managed_tenant_onboarding.activation') + ->exists())->toBeFalse(); +}); + +it('blocks onboarding activation with a suspended read-only commercial-state reason before tenant mutation', function (): void { + $context = readyOnboardingEntitlementContext( + activeTenantCount: 0, + commercialState: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + ); + + $context['component'] + ->assertSee('Activation entitlement') + ->assertSee('Suspended / read-only') + ->assertSee('This workspace is suspended / read-only. New managed-tenant activation is blocked') + ->call('completeOnboarding'); + + $context['tenant']->refresh(); + + expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING) + ->and(AuditLog::query() + ->where('workspace_id', (int) $context['workspace']->getKey()) + ->where('action', 'managed_tenant_onboarding.activation') + ->exists())->toBeFalse(); +}); diff --git a/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php b/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php index ee22abed..587063a2 100644 --- a/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php +++ b/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php @@ -10,12 +10,12 @@ $checks = [ [ 'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php', - 'required' => [ - 'FindingsLifecycleBackfillRunbookService', - 'OperationalControlBlockedException', - 'FindingsLifecycleBackfillScope::singleTenant(', - ], + 'required' => [], 'forbidden' => [ + 'FindingsLifecycleBackfillRunbookService', + 'FindingsLifecycleBackfillScope', + 'Backfill findings lifecycle', + 'backfill_lifecycle', "config('tenantpilot.allow_admin_maintenance_actions'", 'allow_admin_maintenance_actions', 'OperationalControlActivation::', @@ -23,12 +23,12 @@ ], [ 'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php', - 'required' => [ - 'FindingsLifecycleBackfillRunbookService', - 'OperationalControlBlockedException', - '$runbookService->start(', - ], + 'required' => [], 'forbidden' => [ + 'FindingsLifecycleBackfillRunbookService', + 'FindingsLifecycleBackfillScope', + 'findings.lifecycle.backfill', + 'Rebuild Findings Lifecycle', 'OperationalControlActivation::', "config('tenantpilot.allow_admin_maintenance_actions'", ], @@ -66,4 +66,16 @@ expect($source)->not->toContain($needle); } } -})->group('surface-guard'); \ No newline at end of file + + foreach ([ + $root.'/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php', + $root.'/app/Console/Commands/TenantpilotRunDeployRunbooks.php', + $root.'/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php', + $root.'/app/Services/Runbooks/FindingsLifecycleBackfillScope.php', + $root.'/app/Jobs/BackfillFindingLifecycleJob.php', + $root.'/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php', + $root.'/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php', + ] as $removedPath) { + expect(file_exists($removedPath))->toBeFalse("Removed findings lifecycle backfill artifact still exists: {$removedPath}"); + } +})->group('surface-guard'); diff --git a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php index e39b2941..f9fed5b6 100644 --- a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php +++ b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php @@ -27,3 +27,10 @@ ->toContain(".:/var/www/repo:ro") ->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo'); }); + +it('keeps the local queue service in code-reloading listen mode', function (): void { + $compose = file_get_contents(repo_path('docker-compose.yml')); + + expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3') + ->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000'); +}); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php index 21aac9dd..1389f2f2 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php @@ -4,8 +4,12 @@ use App\Models\ReviewPack; use App\Models\AuditLog; +use App\Models\PlatformUser; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\ReviewPackService; +use App\Services\Settings\SettingsWriter; use App\Support\Audit\AuditActionId; +use App\Support\Auth\PlatformCapabilities; use App\Support\ReviewPackStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; @@ -38,6 +42,23 @@ function createReadyPackWithFile(?array $packOverrides = []): array return [$user, $tenant, $pack]; } +function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $pack->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Download preservation test', + ); +} + // ─── Happy Path: Signed URL → 200 ─────────────────────────── it('downloads a ready pack via signed URL with correct headers', function (): void { @@ -64,6 +85,21 @@ function createReadyPackWithFile(?array $packOverrides = []): array ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); }); +it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void { + [$user, $tenant, $pack] = createReadyPackWithFile(); + suspendReadyPackWorkspaceForDownloadTest($pack); + + $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [ + 'source_surface' => 'suspended_read_only_check', + ]); + + $response = $this->actingAs($user)->get($signedUrl); + + $response->assertOk(); + $response->assertHeader('X-Review-Pack-SHA256', $pack->sha256); + $response->assertDownload(); +}); + // ─── Expired Signature → 403 ──────────────────────────────── it('rejects requests with an expired signature', function (): void { diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php index 0e74d326..16c008a0 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php @@ -8,16 +8,20 @@ use App\Models\EvidenceSnapshot; use App\Models\Finding; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\StoredReport; use App\Models\Tenant; use App\Models\User; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Evidence\EvidenceSnapshotService; use App\Services\ReviewPackService; use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\OperationRunType; use App\Support\Workspaces\WorkspaceContext; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; @@ -108,6 +112,23 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str ); } +function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, string $reason = 'Review pack commercial lifecycle test'): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: $state, + reason: $reason, + ); +} + it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); seedEntitlementReviewPackSnapshot($tenant); @@ -187,4 +208,87 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str ->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant')) ->assertOk() ->assertSee('Download'); -}); \ No newline at end of file +}); + +it('allows review pack generation in trial and active paid states', function (string $state): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + setReviewPackCommercialLifecycleState($tenant, $state); + + $pack = app(ReviewPackService::class)->generate($tenant, $user); + + expect($pack)->toBeInstanceOf(ReviewPack::class) + ->and($pack->operation_run_id)->not->toBeNull() + ->and($pack->status)->toBe(\App\Support\ReviewPackStatus::Queued->value); +})->with([ + 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL], + 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID], +]); + +it('warns but allows review pack generation in grace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace period'); + + $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant); + + expect($decision) + ->toMatchArray([ + 'is_blocked' => false, + 'is_warning' => true, + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN, + ]) + ->and($decision['warning_reason'])->toContain('grace'); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + Livewire::actingAs($user) + ->test(TenantReviewPackCard::class, ['record' => $tenant]) + ->assertSee('Workspace is in grace. Review-pack starts remain available'); + + $pack = app(ReviewPackService::class)->generate($tenant, $user); + + expect($pack)->toBeInstanceOf(ReviewPack::class) + ->and(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->exists())->toBeTrue(); +}); + +it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void { + Notification::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspension'); + $initialRunCount = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->count(); + + expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user)) + ->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only'); + + expect(ReviewPack::query()->count())->toBe(0) + ->and(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->count())->toBe($initialRunCount); + + Notification::assertNothingSent(); +}); + +it('does not alter already queued review-pack work when a workspace is suspended later', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + + $pack = app(ReviewPackService::class)->generate($tenant, $user); + $initialStatus = (string) $pack->fresh()?->status; + setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Later suspension'); + + expect($pack->fresh()?->status)->toBe($initialStatus) + ->and(OperationRun::query() + ->whereKey((int) $pack->operation_run_id) + ->exists())->toBeTrue(); +}); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php index 49898eda..7f8392c7 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php @@ -3,18 +3,23 @@ declare(strict_types=1); use App\Exceptions\ReviewPackEvidenceResolutionException; +use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; use App\Filament\Widgets\Tenant\TenantReviewPackCard; use App\Jobs\GenerateReviewPackJob; use App\Models\EvidenceSnapshot; use App\Models\Finding; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\StoredReport; use App\Models\Tenant; use App\Notifications\OperationRunCompleted; use App\Notifications\OperationRunQueued; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Evidence\EvidenceSnapshotService; use App\Services\ReviewPackService; +use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -157,6 +162,23 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot return $snapshot->load('items'); } +function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Generation notification boundary test', + ); +} + // ─── Happy Path ────────────────────────────────────────────── it('generates a review pack end-to-end (happy path)', function (): void { @@ -210,6 +232,22 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot Notification::assertSentTo($user, OperationRunCompleted::class); }); +it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void { + [$user, $tenant] = createUserWithTenant(); + + seedTenantWithData($tenant); + createEvidenceSnapshotForReviewPack($tenant); + suspendReviewPackGenerationWorkspaceForGenerationTest($tenant); + + Notification::fake(); + + expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user)) + ->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only'); + + Notification::assertNotSentTo($user, OperationRunQueued::class); + Notification::assertNotSentTo($user, OperationRunCompleted::class); +}); + // ─── Failure Path ────────────────────────────────────────────── it('marks pack as failed when generation throws an exception', function (): void { diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php index 3fc30c6d..7cd1a4a4 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php @@ -3,8 +3,12 @@ declare(strict_types=1); use App\Filament\Pages\Reviews\CustomerReviewWorkspace; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\Tenant; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; +use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\TenantReviewStatus; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -12,6 +16,23 @@ uses(RefreshDatabase::class); +function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Customer review workspace suspended read-only test', + ); +} + it('shows the ready review-pack action for the latest published review', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); @@ -48,6 +69,44 @@ ->assertSee('Available'); }); +it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'expires_at' => now()->addDay(), + ]); + + $review->forceFill([ + 'current_export_review_pack_id' => (int) $pack->getKey(), + ])->save(); + + suspendCustomerReviewWorkspacePackAccessWorkspace($tenant); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertTableActionVisible('open_latest_review', $tenant) + ->assertTableActionVisible('download_review_pack', $tenant) + ->assertSee('Available'); +}); + it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); @@ -94,4 +153,4 @@ ->assertTableActionHidden('open_latest_review', $tenant) ->assertTableActionHidden('download_review_pack', $tenant) ->assertSee('No published review available yet'); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php b/apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php new file mode 100644 index 00000000..74c62861 --- /dev/null +++ b/apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php @@ -0,0 +1,42 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_CONTROLS_MANAGE, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + $this->get(Controls::getUrl(panel: 'system')) + ->assertSuccessful() + ->assertDontSee('Findings lifecycle backfill') + ->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false) + ->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false) + ->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false); + + $catalog = app(OperationalControlCatalog::class); + + expect($catalog->keys())->not->toContain('findings.lifecycle.backfill') + ->and(fn (): array => $catalog->definition('findings.lifecycle.backfill')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php deleted file mode 100644 index 7b5f87e9..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php +++ /dev/null @@ -1,87 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('does not crash when audit logging fails and still finalizes a failed run', function () { - $this->mock(AuditLogger::class, function ($mock): void { - $mock->shouldReceive('log')->andThrow(new RuntimeException('audit unavailable')); - }); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - $runbook = app(FindingsLifecycleBackfillRunbookService::class); - - $run = $runbook->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: $user, - reason: null, - source: 'system_ui', - ); - - $runs = app(OperationRunService::class); - - $runs->updateRun( - $run, - status: 'completed', - outcome: 'failed', - failures: [ - [ - 'code' => 'test.failed', - 'message' => 'Forced failure for audit fail-safe test.', - ], - ], - ); - - $runbook->maybeFinalize($run); - - $run->refresh(); - - expect($run->status)->toBe('completed'); - expect($run->outcome)->toBe('failed'); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php deleted file mode 100644 index 8d2dc271..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php +++ /dev/null @@ -1,111 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); - - config()->set('tenantpilot.break_glass.enabled', true); - config()->set('tenantpilot.break_glass.ttl_minutes', 15); -}); - -it('requires a reason when break-glass is active and records break-glass on the run + audit', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $customerTenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $customerTenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - PlatformCapabilities::USE_BREAK_GLASS, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Dashboard::class) - ->callAction('enter_break_glass', data: [ - 'reason' => 'Recovery test', - ]) - ->assertHasNoActionErrors(); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $customerTenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertHasActionErrors(['reason_code', 'reason_text']); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $customerTenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'reason_code' => 'INCIDENT', - 'reason_text' => 'Break-glass backfill required', - ]) - ->assertHasNoActionErrors() - ->assertNotified(); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - expect((int) $run?->tenant_id)->toBe((int) $customerTenant->getKey()); - expect(data_get($run?->context, 'platform_initiator.is_break_glass'))->toBeTrue(); - expect(data_get($run?->context, 'reason.reason_code'))->toBe('INCIDENT'); - expect(data_get($run?->context, 'reason.reason_text'))->toBe('Break-glass backfill required'); - - $audit = AuditLog::query() - ->where('action', 'platform.ops.runbooks.start') - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull(); - expect($audit?->metadata['is_break_glass'] ?? null)->toBe(true); - expect($audit?->metadata['reason_code'] ?? null)->toBe('INCIDENT'); - expect($audit?->metadata['reason_text'] ?? null)->toBe('Break-glass backfill required'); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php deleted file mode 100644 index 645a695d..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php +++ /dev/null @@ -1,78 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('is idempotent: after a successful run, preflight reports nothing to do', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $runbook = app(FindingsLifecycleBackfillRunbookService::class); - - $initial = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); - - expect($initial['affected_count'])->toBe(1); - - $runbook->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: null, - reason: null, - source: 'system_ui', - ); - - $job = new BackfillFindingLifecycleJob( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: null, - ); - - $job->handle( - app(OperationRunService::class), - app(\App\Services\Findings\FindingSlaPolicy::class), - $runbook, - ); - - $after = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); - - expect($after['affected_count'])->toBe(0); - - expect(fn () => $runbook->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: null, - reason: null, - source: 'system_ui', - ))->toThrow(ValidationException::class); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php deleted file mode 100644 index 131b8d49..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php +++ /dev/null @@ -1,202 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('computes single-tenant preflight counts', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - ]); - - $service = app(FindingsLifecycleBackfillRunbookService::class); - - $result = $service->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); - - expect($result['total_count'])->toBe(2); - expect($result['affected_count'])->toBe(1); -}); - -it('computes all-tenants preflight counts scoped to the platform workspace', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenantA = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - $tenantB = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - $otherTenant = Tenant::factory()->create(); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantA->getKey(), - 'due_at' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantB->getKey(), - 'sla_days' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $otherTenant->getKey(), - 'due_at' => null, - ]); - - $service = app(FindingsLifecycleBackfillRunbookService::class); - - $result = $service->preflight(FindingsLifecycleBackfillScope::allTenants()); - - expect($result['estimated_tenants'])->toBe(2); - expect($result['total_count'])->toBe(2); - expect($result['affected_count'])->toBe(2); -}); - -it('accepts an allowed single-tenant selection during preflight', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $tenant->getKey(), - ]) - ->assertHasNoActionErrors() - ->assertSet('findingsTenantId', (int) $tenant->getKey()) - ->assertSet('preflight.affected_count', 1); -}); - -it('rejects platform tenant selection during preflight', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - assertScopedSelectorRejected( - Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class), - 'preflight', - [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $platformTenant->getKey(), - ], - ); -}); - -it('resets to an all-tenant trusted scope even when stale single-tenant selector state remains on the page', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenantA = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - 'name' => 'Scope Tenant A', - ]); - - $tenantB = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - 'name' => 'Scope Tenant B', - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantA->getKey(), - 'due_at' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantB->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class) - ->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('findingsTenantId', (int) $tenantA->getKey()) - ->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('tenantId', (int) $tenantA->getKey()) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - 'tenant_id' => (int) $tenantA->getKey(), - ]) - ->assertHasNoActionErrors() - ->assertSet('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->assertSet('findingsTenantId', null) - ->assertSet('scopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->assertSet('tenantId', null) - ->assertSet('preflight.estimated_tenants', 2) - ->assertSet('preflight.affected_count', 2); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php deleted file mode 100644 index 0d472d75..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php +++ /dev/null @@ -1,253 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('disables running when preflight indicates nothing to do', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 0) - ->assertActionDisabled('run'); -}); - -it('requires typed confirmation and a reason for all-tenants runs', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertHasActionErrors([ - 'typed_confirmation', - 'reason_code', - 'reason_text', - ]); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'typed_confirmation' => 'backfill', - 'reason_code' => 'DATA_REPAIR', - 'reason_text' => 'Test run', - ]) - ->assertHasActionErrors(['typed_confirmation']); -}); - -it('rejects forged single-tenant selector state on run and records no run or start audit', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $allowedTenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $allowedTenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $allowedTenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('findingsTenantId', (int) $platformTenant->getKey()) - ->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('tenantId', (int) $platformTenant->getKey()) - ->callAction('run', data: []) - ->assertHasActionErrors(); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0) - ->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0); -}); - -it('records a start audit with the canonical single-tenant scope when an allowed run is queued', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $tenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertHasNoActionErrors() - ->assertNotified('Findings lifecycle backfill queued'); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - - $audit = AuditLog::query() - ->where('action', 'platform.ops.runbooks.start') - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull() - ->and($audit?->resource_id)->toBe((string) $run?->getKey()) - ->and($audit?->metadata['scope'] ?? null)->toBe(FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->and($audit?->metadata['target_tenant_id'] ?? null)->toBe((int) $tenant->getKey()) - ->and($audit?->metadata['operation_run_id'] ?? null)->toBe((int) $run?->getKey()); -}); - -it('returns 403 for runbook execution when the platform user is in scope but lacks run capability', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $tenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertForbidden(); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0) - ->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php deleted file mode 100644 index 3c61ef99..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php +++ /dev/null @@ -1,89 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - OperationalControlActivation::factory()->forGlobalScope()->create([ - 'control_key' => 'findings.lifecycle.backfill', - 'reason_text' => 'Paused during incident response.', - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => 'all_tenants', - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'typed_confirmation' => 'BACKFILL', - 'reason_code' => 'DATA_REPAIR', - 'reason_text' => 'Attempt blocked by control', - ]) - ->assertNotified('Findings lifecycle backfill paused'); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0); - - $audit = AuditLog::query() - ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull() - ->and($audit?->workspace_id)->toBeNull() - ->and($audit?->tenant_id)->toBeNull() - ->and($audit?->status)->toBe('blocked') - ->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill') - ->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants'); -}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php deleted file mode 100644 index a2d95695..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php +++ /dev/null @@ -1,89 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('uses an intent-only toast with a working view-run link and does not emit queued database notifications', function () { - Queue::fake(); - NotificationFacade::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'typed_confirmation' => 'BACKFILL', - 'reason_code' => 'DATA_REPAIR', - 'reason_text' => 'Operator test', - ]) - ->assertHasNoActionErrors() - ->assertNotified('Findings lifecycle backfill queued'); - - NotificationFacade::assertNothingSent(); - expect(DatabaseNotification::query()->count())->toBe(0); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - - $viewUrl = SystemOperationRunLinks::view($run); - - $this->get($viewUrl) - ->assertSuccessful() - ->assertSee('Operation #'.(int) $run?->getKey()); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php new file mode 100644 index 00000000..7caeced1 --- /dev/null +++ b/apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php @@ -0,0 +1,59 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + $this->get(Runbooks::getUrl(panel: 'system')) + ->assertSuccessful() + ->assertSee('No supported runbooks') + ->assertDontSee('Rebuild Findings Lifecycle') + ->assertDontSee('Backfills legacy findings lifecycle fields') + ->assertDontSee('Preflight') + ->assertDontSee('preflight') + ->assertDontSee('Run: Rebuild Findings Lifecycle') + ->assertDontSee('BACKFILL'); + + Livewire::test(Runbooks::class) + ->assertActionDoesNotExist('preflight') + ->assertActionDoesNotExist('run'); +}); + +it('preserves runbooks view authorization semantics after removing the backfill runbook', function (): void { + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform') + ->get(Runbooks::getUrl(panel: 'system')) + ->assertForbidden(); +}); diff --git a/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php b/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php index 232fc0fc..55a1d08c 100644 --- a/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php +++ b/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php @@ -5,7 +5,9 @@ use App\Models\OperationRun; use App\Models\PlatformUser; use App\Models\User; +use App\Models\Workspace; use App\Support\Auth\PlatformCapabilities; +use App\Support\System\SystemDirectoryLinks; use App\Support\System\SystemOperationRunLinks; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -119,3 +121,38 @@ ->get('/system/ops/runbooks') ->assertSuccessful(); }); + +it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void { + $workspace = Workspace::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->get(SystemDirectoryLinks::workspaceDetail($workspace)) + ->assertNotFound(); + + auth()->guard('web')->logout(); + + $platformWithoutDirectoryView = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ], + 'is_active' => true, + ]); + + $this->actingAs($platformWithoutDirectoryView, 'platform') + ->get(SystemDirectoryLinks::workspaceDetail($workspace)) + ->assertForbidden(); + + $directoryViewer = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($directoryViewer, 'platform') + ->get(SystemDirectoryLinks::workspaceDetail($workspace)) + ->assertSuccessful() + ->assertSee('Commercial lifecycle') + ->assertDontSee('Change commercial state'); +}); diff --git a/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php b/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php index e0d8fad1..88514d6d 100644 --- a/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php +++ b/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php @@ -3,13 +3,25 @@ declare(strict_types=1); use App\Filament\System\Pages\Directory\ViewWorkspace; +use App\Models\AuditLog; use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Models\WorkspaceSetting; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Settings\SettingsWriter; +use App\Support\Audit\AuditActionId; use App\Support\Auth\PlatformCapabilities; +use Filament\Actions\Action; +use Filament\Facades\Filament; +use Livewire\Livewire; + +beforeEach(function (): void { + Filament::setCurrentPanel('system'); + Filament::bootCurrentPanel(); +}); it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void { $workspace = Workspace::factory()->create(['name' => 'Acme Workspace']); @@ -79,5 +91,102 @@ ->assertSee('Pilot workspace') ->assertSee('Escalation only') ->assertSee('workspace override') + ->assertSee('Commercial lifecycle') + ->assertSee('Active paid') + ->assertSee('default active paid') ->assertDontSee('Save'); -}); \ No newline at end of file +}); + +it('gates the commercial lifecycle mutation action behind a dedicated platform capability', function (): void { + $workspace = Workspace::factory()->create(); + + $viewer = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($viewer, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->assertActionHidden('change_commercial_state'); +}); + +it('changes commercial lifecycle state through the confirmed system action and records audit truth', function (): void { + $workspace = Workspace::factory()->create(['name' => 'Lifecycle Workspace']); + $operator = PlatformUser::factory()->create([ + 'name' => 'Platform Operator', + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($operator, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->assertActionVisible('change_commercial_state') + ->assertActionExists('change_commercial_state', fn (Action $action): bool => $action->getLabel() === 'Change commercial state' + && $action->isConfirmationRequired()) + ->callAction('change_commercial_state', data: [ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + 'reason' => 'Commercial suspension approved by support', + ]) + ->assertNotified('Commercial state updated'); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN) + ->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE) + ->value('value'))->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY) + ->and(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN) + ->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON) + ->value('value'))->toBe('Commercial suspension approved by support'); + + $audit = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceSettingUpdated->value) + ->where('resource_id', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->actor_name)->toBe('Platform Operator') + ->and($audit?->metadata['before_state'] ?? null)->toBeNull() + ->and($audit?->metadata['after_state'] ?? null)->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY) + ->and($audit?->metadata['after_reason'] ?? null)->toBe('Commercial suspension approved by support'); + + $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); + + expect($summary) + ->toMatchArray([ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING, + 'rationale' => 'Commercial suspension approved by support', + 'last_changed_by' => 'Platform Operator', + ]); +}); + +it('requires a rationale before changing commercial lifecycle state', function (): void { + $workspace = Workspace::factory()->create(); + $operator = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($operator, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->callAction('change_commercial_state', data: [ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, + 'reason' => '', + ]) + ->assertHasActionErrors(['reason']); +}); diff --git a/apps/platform/tests/Pest.php b/apps/platform/tests/Pest.php index c57f5b1d..326e9b21 100644 --- a/apps/platform/tests/Pest.php +++ b/apps/platform/tests/Pest.php @@ -164,6 +164,25 @@ function something() // .. } +/** + * @return array{0: Workspace, 1: User} + */ +function localizationWorkspaceMember(string $role = 'manager'): array +{ + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => $role, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + return [$workspace, $user]; +} + function repo_root(): string { $configuredRoot = env('TENANTATLAS_REPO_ROOT'); diff --git a/apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php b/apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php new file mode 100644 index 00000000..e5279451 --- /dev/null +++ b/apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php @@ -0,0 +1,20 @@ +label)->toBe($label) + ->and($spec->color)->toBe($color) + ->and($spec->icon)->not->toBeNull(); +})->with([ + 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial', 'info'], + 'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace', 'warning'], + 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid', 'success'], + 'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only', 'danger'], +]); diff --git a/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php b/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php new file mode 100644 index 00000000..083a5326 --- /dev/null +++ b/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php @@ -0,0 +1,199 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + return [$workspace, $user]; +} + +function commercialLifecyclePlatformOperator(): PlatformUser +{ + return PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]); +} + +function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: commercialLifecyclePlatformOperator(), + workspace: $workspace, + state: $state, + reason: $reason, + ); +} + +it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + + $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); + + expect($summary) + ->toMatchArray([ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, + 'state_label' => 'Active paid', + 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID, + 'source_label' => 'default active paid', + 'rationale' => null, + ]) + ->and($summary['last_changed_at'])->toBeNull() + ->and($summary['last_changed_by'])->toBeNull() + ->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome']) + ->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW) + ->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome']) + ->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW); +}); + +it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + $operator = commercialLifecyclePlatformOperator(); + + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: $operator, + workspace: $workspace, + state: $state, + reason: 'Support approved commercial lifecycle transition', + ); + + $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); + + expect($summary) + ->toMatchArray([ + 'state' => $state, + 'state_label' => $expectedLabel, + 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING, + 'source_label' => 'workspace setting', + 'rationale' => 'Support approved commercial lifecycle transition', + 'last_changed_by' => $operator->name, + ]) + ->and($summary['last_changed_at'])->not->toBeNull(); +})->with([ + 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'], + 'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'], + 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'], + 'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'], +]); + +it('blocks activation but warns review pack starts during grace', function (): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending'); + + $resolver = app(WorkspaceCommercialLifecycleResolver::class); + $activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION); + $reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START); + + expect($activation) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'is_blocked' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, + ]) + ->and($activation['block_reason'])->toContain('grace') + ->and($reviewPackStart) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN, + 'is_blocked' => false, + 'is_warning' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, + ]) + ->and($reviewPackStart['warning_reason'])->toContain('grace'); +}); + +it('blocks new starts but allows read-only history during suspended read-only', function (): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension'); + + $resolver = app(WorkspaceCommercialLifecycleResolver::class); + + expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'is_blocked' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + ]) + ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'is_blocked' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + ]) + ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY, + 'is_blocked' => false, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + ]); +}); + +it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void { + [$workspace, $manager] = commercialLifecycleWorkspaceManager(); + setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate'); + + Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ACTIVE, + ]); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $manager, + workspace: $workspace, + domain: WorkspaceEntitlementResolver::SETTING_DOMAIN, + key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE, + value: 1, + ); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $manager, + workspace: $workspace, + domain: WorkspaceEntitlementResolver::SETTING_DOMAIN, + key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE, + value: false, + ); + + $resolver = app(WorkspaceCommercialLifecycleResolver::class); + + expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + ]) + ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + 'is_warning' => false, + ]); +}); diff --git a/apps/platform/tests/Unit/Localization/LocaleResolverTest.php b/apps/platform/tests/Unit/Localization/LocaleResolverTest.php new file mode 100644 index 00000000..91e75f3b --- /dev/null +++ b/apps/platform/tests/Unit/Localization/LocaleResolverTest.php @@ -0,0 +1,83 @@ +resolveFromSources('de', 'en', 'en', 'en')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE, + 'machine_artifacts_invariant' => true, + ]); + + expect($resolver->resolveFromSources(null, 'de', 'en', 'en')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_USER_PREFERENCE, + ]); + + expect($resolver->resolveFromSources(null, null, 'de', 'en')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT, + ]); + + expect($resolver->resolveFromSources(null, null, null, 'de')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT, + ]); +}); + +it('falls through unsupported locale sources safely', function (): void { + $resolver = unitLocaleResolver(); + + $context = $resolver->resolveFromSources('fr', 'es', 'de', 'en'); + + expect($context) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT, + 'fallback_locale' => 'en', + ]) + ->and($context['user_preference_locale'])->toBeNull(); +}); + +it('keeps system panel resolution to explicit override or system default only', function (): void { + $resolver = unitLocaleResolver(); + + expect($resolver->resolveFromSources( + explicitOverride: null, + userPreference: 'de', + workspaceDefault: 'de', + systemDefault: 'en', + includeUserPreference: false, + includeWorkspaceDefault: false, + ))->toMatchArray([ + 'locale' => 'en', + 'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT, + 'user_preference_locale' => null, + 'workspace_default_locale' => null, + ]); + + expect($resolver->resolveFromSources( + explicitOverride: 'de', + userPreference: 'en', + workspaceDefault: 'en', + systemDefault: 'en', + includeUserPreference: false, + includeWorkspaceDefault: false, + ))->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE, + ]); +}); diff --git a/apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php b/apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php new file mode 100644 index 00000000..af2f156e --- /dev/null +++ b/apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php @@ -0,0 +1,14 @@ +toBeFalse() + ->and(PlatformCapabilities::all())->not->toContain('platform.runbooks.findings.lifecycle_backfill'); + + expect((string) file_get_contents(database_path('seeders/PlatformUserSeeder.php'))) + ->not->toContain('RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL') + ->not->toContain('platform.runbooks.findings.lifecycle_backfill'); +}); diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php new file mode 100644 index 00000000..938561ac --- /dev/null +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -0,0 +1,197 @@ +create(); + $user = User::factory()->create(); + + $alphaTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + $bravoTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + Finding::factory() + ->for($alphaTenant) + ->assignedTo((int) $user->getKey()) + ->ownedBy((int) $user->getKey()) + ->overdueByHours() + ->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + 'subject_external_id' => 'assigned-finding', + ]); + + Finding::factory() + ->for($bravoTenant) + ->reopened() + ->create([ + 'subject_external_id' => 'intake-finding', + ]); + + OperationRun::factory() + ->forTenant($alphaTenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + OperationRun::factory() + ->forTenant($bravoTenant) + ->create([ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(6), + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + 'event_type' => 'alerts.failed_delivery', + 'payload' => [ + 'title' => 'Delivery failed', + 'body' => 'Alert delivery could not be completed.', + ], + ]); + + $backupHealthResolver = app(TenantBackupHealthResolver::class); + $fingerprints = app(TenantTriageReviewFingerprint::class); + + $alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant)); + $bravoBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($bravoTenant)); + + expect($alphaBackupFingerprint)->not->toBeNull() + ->and($bravoBackupFingerprint)->not->toBeNull(); + + TenantTriageReview::factory() + ->for($alphaTenant) + ->followUpNeeded() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => $alphaBackupFingerprint['fingerprint'], + 'review_snapshot' => $alphaBackupFingerprint['snapshot'], + 'reviewed_at' => now()->subDay(), + ]); + + TenantTriageReview::factory() + ->for($bravoTenant) + ->reviewed() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => hash('sha256', 'stale-review-fingerprint'), + 'review_snapshot' => $bravoBackupFingerprint['snapshot'], + 'reviewed_at' => now()->subDays(2), + ]); + + $context = new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: 'filament.admin.pages.governance.inbox', + backLinkLabel: 'Back to governance inbox', + backLinkUrl: '/admin/governance/inbox', + ); + + $payload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$alphaTenant, $bravoTenant], + visibleFindingTenants: [$alphaTenant, $bravoTenant], + reviewTenants: [$alphaTenant, $bravoTenant], + canViewAlerts: true, + navigationContext: $context, + ); + + expect(collect($payload['sections'])->pluck('key')->all()) + ->toBe([ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ]) + ->and($payload['family_counts'])->toMatchArray([ + 'assigned_findings' => 1, + 'intake_findings' => 1, + 'stale_operations' => 2, + 'alert_delivery_failures' => 1, + 'review_follow_up' => 2, + ]); + + $sections = collect($payload['sections'])->keyBy('key'); + + expect($sections['assigned_findings']['dominant_action_url']) + ->toContain('/admin/findings/my-work') + ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') + ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') + ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') + ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') + ->and(collect($sections['review_follow_up']['entries'])->pluck('status_label')->all()) + ->toBe(['Follow-up needed', 'Changed since review']); +}); + +it('keeps an explicitly selected visible family with an honest empty state when tenant filtering removes every row', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $alphaTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + $bravoTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $payload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$alphaTenant, $bravoTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: true, + selectedTenant: $alphaTenant, + selectedFamily: 'alert_delivery_failures', + ); + + expect($payload['sections'])->toHaveCount(1) + ->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures') + ->and($payload['sections'][0]['count'])->toBe(0) + ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php b/apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php new file mode 100644 index 00000000..0e7adf1f --- /dev/null +++ b/apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php @@ -0,0 +1,12 @@ +not->toHaveKey('findings.lifecycle.backfill') + ->and(OperationCatalog::aliasInventory())->not->toHaveKey('findings.lifecycle.backfill') + ->and(OperationCatalog::rawValuesForCanonical('findings.lifecycle.backfill'))->toBe([]) + ->and(OperationCatalog::label('findings.lifecycle.backfill'))->toBe('Unknown operation'); +}); diff --git a/apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php b/apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php new file mode 100644 index 00000000..a090a43f --- /dev/null +++ b/apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php @@ -0,0 +1,52 @@ +create([ + 'slug' => 'resolver-smoke-workspace', + ]); + + $resolver = app(WorkspaceResolver::class); + + expect($resolver->resolve('resolver-smoke-workspace')?->is($workspace))->toBeTrue() + ->and($resolver->resolve((string) $workspace->getKey())?->is($workspace))->toBeTrue(); +}); + +it('resolves a Livewire serialized workspace route parameter', function (): void { + $workspace = Workspace::factory()->create([ + 'slug' => 'serialized-route-workspace', + ]); + + $payload = json_encode([ + 'id' => $workspace->getKey(), + 'name' => $workspace->name, + 'slug' => $workspace->slug, + ], JSON_THROW_ON_ERROR); + + expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue(); +}); + +it('falls back to serialized id when a Livewire route payload has no slug', function (): void { + $workspace = Workspace::factory()->create(); + + $payload = json_encode([ + 'id' => (string) $workspace->getKey(), + ], JSON_THROW_ON_ERROR); + + expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue(); +}); + +it('returns null for an unsupported serialized route payload', function (): void { + $payload = json_encode([ + 'name' => 'Missing key', + ], JSON_THROW_ON_ERROR); + + expect(app(WorkspaceResolver::class)->resolve($payload))->toBeNull(); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 20e950b0..cd60f661 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: - laravel.test - pgsql - redis - command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000 + command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3 pgsql: image: 'postgres:16' diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 071d1139..06362df3 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -473,7 +473,6 @@ ### Deployment: Dokploy (staging → production) ### Platform runbooks -- `FindingsLifecycleBackfillRunbookService` ([app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php](app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php)) — safe backfill of findings lifecycle fields - Accessible at `/system/ops/runbooks` with platform capabilities --- diff --git a/docs/product/implementation-ledger.md b/docs/product/implementation-ledger.md index 5be9ca39..b5f12e4d 100644 --- a/docs/product/implementation-ledger.md +++ b/docs/product/implementation-ledger.md @@ -15,7 +15,7 @@ ## Purpose ## Current Product Position -TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. +TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. ## Status Model @@ -51,7 +51,7 @@ ## Roadmap Coverage Summary | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | | R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. | -| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. | +| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | @@ -106,7 +106,7 @@ ## Foundation-Only Capabilities ## Partial Capabilities - Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt. -- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer. +- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. @@ -179,6 +179,9 @@ ## Open Gaps & Blockers |---|---|---|---|---| | Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 | | No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 | +| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces | +| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility | +| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | | Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | @@ -191,6 +194,9 @@ ## Recommended Next Specs - `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface. - `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface. +- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks. +- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases. +- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. - `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 4a2e0b33..a7fd663e 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -3,7 +3,7 @@ # Spec Candidates > Repo-based next-spec queue for TenantPilot. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. -> **Last reviewed**: 2026-04-27 +> **Last reviewed**: 2026-04-28 > **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth --- @@ -138,6 +138,94 @@ ### Localization v1 - locale-aware formatting does not affect audit or export truth - targeted regression coverage exists for fallback and key critical flows +### Remove Findings Lifecycle Backfill Runtime Surfaces +- **Priority**: P1 +- **Why this stays active**: Repo audit shows visible runtime surfaces for a pre-production findings lifecycle repair path even though active finding generators already write the relevant lifecycle fields directly. The remaining path is not just ballast; it appears partially detached from current operational-control truth and keeps internal repair tooling productized. +- **Roadmap relationship**: Findings workflow cleanup / legacy removal. +- **Dependencies**: + - current finding generators that already set lifecycle fields directly + - system runbook registry and execution surfaces + - tenant findings actions + - operation catalog, capability, and seeder bindings + - backfill jobs, runbook service, and deploy hooks +- **Scope**: + - remove the system runbook `Rebuild Findings Lifecycle` + - remove the tenant action `Backfill findings lifecycle` + - remove the command `tenantpilot:findings:backfill-lifecycle` + - remove findings lifecycle backfill jobs, runbook services, and deploy/runtime hooks + - remove operation-catalog, capability, seeder, and test traces that exist only for this backfill path +- **Non-scope**: + - removing the legacy `acknowledged` status or related compatibility helpers + - changing normal finding workflow actions such as triage, assignment, progress, resolve, or risk acceptance + - changing ownership, assignee, SLA, due-date, or risk-governance semantics + - changing historical migrations or adding replacement backfills +- **Acceptance criteria**: + - no `/admin` surface exposes `Backfill findings lifecycle` + - no system runbook exposes `Rebuild Findings Lifecycle` + - `tenantpilot:findings:backfill-lifecycle` is no longer a supported command + - deploy or operational hooks do not start a findings lifecycle backfill + - `findings.lifecycle.backfill` is no longer used as an operational-control key, operation type, or capability + - tests no longer expect backfill preflight, start, or completion behavior + - normal finding workflows keep working unchanged for triage, assignment, start progress, resolve, and risk acceptance +- **Notes**: This is the first and most important cleanup candidate because it removes visible product ballast without changing the canonical findings workflow semantics. + +### Remove Legacy Acknowledged Finding Status Compatibility +- **Priority**: P1 +- **Why this stays active**: Repo audit indicates that `acknowledged` compatibility still survives in status helpers, filters, badges, capabilities, and tests even though the current operator workflow is centered on `triaged`. Keeping both semantics alive weakens workflow clarity and RBAC consistency. +- **Roadmap relationship**: Findings workflow semantics / RBAC cleanup. +- **Dependencies**: + - finding status constants and model helpers + - badge and filter catalogs + - role capability mappings and capability aliases + - workflow and bulk-action tests that still speak in acknowledge semantics +- **Scope**: + - remove `Finding::STATUS_ACKNOWLEDGED` + - remove or simplify compatibility helpers that only map `acknowledged` to `triaged` + - remove `openStatusesForQuery()` compatibility for `acknowledged` + - remove legacy capability aliases such as `tenant_findings.acknowledge` + - rename, adapt, or remove tests that only protect the old acknowledge vocabulary + - ensure active workflow actions consistently use `triage` / `triaged` +- **Non-scope**: + - removing findings lifecycle backfill runtime surfaces in the same slice + - changing SLA, ownership, assignee, or risk-acceptance behavior + - introducing new workflow states or new customer-facing workflow surfaces + - changing finding generators unless they still emit `acknowledged` +- **Acceptance criteria**: + - no productive code path writes `acknowledged` + - no productive code path expects `acknowledged` as a valid workflow status + - `tenant_findings.acknowledge` no longer exists as a capability or alias + - workflow actions, filters, badges, and tests consistently use `triage` / `triaged` + - existing finding flows remain functional from `new` to `triaged`, `in_progress`, `resolved`, and risk-accepted outcomes +- **Notes**: Keep this separate from backfill removal because it reaches deeper into workflow semantics, queries, badges, and RBAC mappings. + +### Enforce Creation-Time Finding Invariants +- **Priority**: P1 +- **Why this stays active**: Removing lifecycle backfills only stays safe if new findings are always created in a lifecycle-ready state. The repo already hints at good direct-write behavior, but those invariants still need explicit protection so future generators do not recreate the need for repair jobs. +- **Roadmap relationship**: Findings data integrity / workflow hardening. +- **Dependencies**: + - drift and baseline compare finding generation + - permission posture finding generation + - Entra admin roles finding generation + - rediscovery, reopen, and deduplication behavior around recurrence keys and lifecycle timestamps +- **Scope**: + - review active finding generators and verify lifecycle-ready creation + - add or tighten invariant tests around canonical status, first/last seen timestamps, `times_seen`, `sla_days`, and `due_at` where applicable + - verify reopen and rediscovery behavior + - verify drift idempotency and recurrence-key semantics + - consider a tightly bounded DB constraint only if the repo proves a safe, narrow case +- **Non-scope**: + - reintroducing any backfill or repair runtime surface + - historical data migration work + - forcing owner or assignee fields to become mandatory + - introducing new finding types or broader customer review workflow changes +- **Acceptance criteria**: + - repo-verified finding generators have tests that prove lifecycle-ready creation + - no new finding generation path relies on a later backfill or repair run + - repeated drift detection does not create uncontrolled canonical duplicates + - reopen or rediscovery behavior updates lifecycle fields correctly + - accountability remains a governance state rather than a forced owner/assignee requirement +- **Notes**: This should follow the visible cleanup work and protects the target state so findings do not regress back into repair-job dependency. + ### P2 — Commercial / Scale ### Commercial Entitlements and Billing-State Maturity diff --git a/specs/250-decision-governance-inbox/checklists/requirements.md b/specs/250-decision-governance-inbox/checklists/requirements.md new file mode 100644 index 00000000..936e80cf --- /dev/null +++ b/specs/250-decision-governance-inbox/checklists/requirements.md @@ -0,0 +1,70 @@ +# Preparation Review Checklist: Decision-Based Governance Inbox v1 + +**Purpose**: Validate the governance inbox preparation package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation +**Created**: 2026-04-28 +**Feature**: [spec.md](../spec.md) + +## Applicability And Low-Impact Gate + +- [x] CHK001 The package explicitly treats this as an operator-facing workspace decision surface, so the low-impact `N/A` path is not used. +- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording. + +## Native, Shared-Family, And State Ownership + +- [x] CHK003 The inbox remains a native Filament page that reuses existing source surfaces instead of introducing a fake-native task console or separate monitoring shell. +- [x] CHK004 Shared families remain shared: findings, operations, alerts, and review follow-up stay on their existing source pages, while the new page stays a routing and decision layer. +- [x] CHK005 Page and URL-query state owners are named once, and the package does not collapse them into new persisted workflow state. +- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: each section has one dominant source CTA and the page owns no mutation lane. + +## Shared Pattern Reuse + +- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and the existing source pages. +- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded `Support/GovernanceInbox/` seam is explicitly constrained as a last resort rather than a new default abstraction. +- [x] CHK009 The package does not create a parallel operator UX language for claim, acknowledge, stale-run handling, or review follow-up; it routes into the current source-family vocabulary. + +## OperationRun Start UX Contract + +- [x] CHK019 The package explicitly states that the inbox only deep-links into existing `OperationRun` detail and does not start, queue, or complete runs. +- [x] CHK020 Canonical operation URLs are delegated to the shared `OperationRunLinks` path rather than recomposed locally on the inbox page. +- [x] CHK021 No queued DB-notification or terminal-notification behavior is added because the slice is read-only. +- [x] CHK022 No OperationRun exception is required; if implementation later adds local run-start or blocked-run messaging, that would be out-of-scope drift. + +## Provider Boundary And Vocabulary + +- [x] CHK010 The package keeps provider-specific semantics behind existing normalized governance, alerting, and review seams and does not spread provider language into a new platform-core contract. +- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays inside existing workspace, tenant, operations, findings, alerts, and review vocabulary. + +## Signals, Exceptions, And Test Depth + +- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package. +- [x] CHK013 No bounded exception is required in the preparation package; if implementation proves a bounded assembly helper is necessary, it must be recorded in the active feature close-out entry. +- [x] CHK014 The required surface test profile is explicit: `global-context-shell`. +- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused `Unit` plus `Feature` coverage only. + +## Audience-Aware Disclosure And Decision Hierarchy + +- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support or raw evidence. +- [x] CHK024 The inbox default path does not expose raw JSON, copied payloads, provider diagnostics, or other debug semantics by default. +- [x] CHK025 Exactly one dominant next action remains primary per section or entry: open the relevant existing source surface. +- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by keeping proof and detailed reasoning on the source pages. +- [x] CHK027 Support/raw sections remain off the inbox page entirely, and the page stays within Filament visual language, progressive disclosure, and calm read-only presentation. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records any bounded assembly-seam exception and the final proof outcome. + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and supporting design artifacts. It does not claim application code exists. +- The slice remains bounded to one read-only workspace decision surface in the current admin plane. No new task engine, no new attention state, and no local mutation lane are approved by this package. +- If implementation later proves that a bounded `Support/GovernanceInbox/` seam is necessary, that must stay derived and page-scoped rather than becoming a generalized workflow framework. + +## Guardrail / Exception / Smoke Coverage + +- Implementation status: complete for the bounded v1 slice. +- Guardrail result: PASS. The implemented page stayed native, read-only, shared-primitives-first, and inside the existing admin plane without adding a new task engine, persisted inbox truth, or local mutation lane. +- Bounded exception result: `document-in-feature`. `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` was added as the smallest readable cross-family assembly seam. +- Validation result: the focused unit and feature proof command passed with `10 passed (53 assertions)`, and dirty-only Pint passed. +- Smoke result: PASS. A manual integrated-browser run on `/admin/governance/inbox` verified route load, canonical operations drill-through with `nav` context, and successful return to the inbox. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml b/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml new file mode 100644 index 00000000..89ce5f8d --- /dev/null +++ b/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml @@ -0,0 +1,159 @@ +openapi: 3.1.0 +info: + title: Decision-Based Governance Inbox v1 + version: 0.1.0 + summary: Conceptual contract for the canonical governance inbox page. +paths: + /admin/governance/inbox: + get: + summary: Render the governance inbox page + description: >- + Returns the derived governance inbox composition for the current workspace actor. + This is a conceptual page contract used for planning, not a public API commitment. + parameters: + - in: query + name: tenant_id + schema: + type: integer + nullable: true + description: Optional tenant prefilter. Out-of-scope values resolve as not found. + - in: query + name: family + schema: + type: string + enum: + - assigned_findings + - intake_findings + - stale_operations + - alert_delivery_failures + - review_follow_up + description: Optional source-family filter. `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention. + - in: query + name: nav[source_surface] + schema: + type: string + description: Optional shared navigation context source. + responses: + '200': + description: Derived governance inbox payload for page rendering. + content: + application/json: + schema: + type: object + required: + - title + - applied_scope + - sections + properties: + title: + type: string + example: Governance inbox + applied_scope: + type: object + properties: + tenant_id: + type: integer + nullable: true + family: + type: string + nullable: true + workspace_scoped: + type: boolean + sections: + type: array + items: + type: object + required: + - key + - label + - count + - summary + - dominant_action + - entries + properties: + key: + type: string + description: Family key; `stale_operations` covers stale and terminal-follow-up operations attention. + label: + type: string + count: + type: integer + summary: + type: string + empty_state: + type: string + nullable: true + description: Family-specific empty-state copy used when the family is explicitly selected but has no visible entries. + dominant_action: + type: object + required: + - label + - url + properties: + label: + type: string + url: + type: string + entries: + type: array + items: + type: object + required: + - family_key + - source_model + - source_key + - headline + - status_label + - destination_url + properties: + family_key: + type: string + description: Matches the owning section key; `stale_operations` covers stale and terminal-follow-up operations attention. + source_model: + type: string + source_key: + type: string + tenant_id: + type: integer + nullable: true + tenant_label: + type: string + nullable: true + headline: + type: string + subline: + type: string + nullable: true + urgency_rank: + type: integer + status_label: + type: string + destination_url: + type: string + back_label: + type: string + nullable: true + '404': + description: Workspace membership missing or explicit tenant prefilter is outside scope. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Not Found + '403': + description: Workspace member is in scope but lacks every qualifying visible-family capability for the inbox. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Forbidden \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/data-model.md b/specs/250-decision-governance-inbox/data-model.md new file mode 100644 index 00000000..cdadd947 --- /dev/null +++ b/specs/250-decision-governance-inbox/data-model.md @@ -0,0 +1,103 @@ +# Data Model: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Model Posture + +This slice introduces no new persisted entity. Every object below is a derived read model used to compose one decision-first page over existing repo truth. + +## Existing Source Truth + +| Source Model | Ownership | Relevant Truth Reused | +|---|---|---| +| `Finding` | tenant-owned | assigned work, intake work, severity, due or overdue state, reopened state, tenant entitlement | +| `OperationRun` | tenant-owned with workspace monitoring access | stale or terminal-follow-up attention, canonical run destination | +| `AlertDelivery` | workspace-scoped | failed or otherwise operator-relevant alert delivery outcomes | +| `TenantReview` | tenant-owned | latest review drill-through destination | +| `TenantTriageReview` | tenant-owned | follow-up-needed and changed-since-review attention | + +## Derived Read Models + +### GovernanceInboxSection + +Represents one visible source family on the inbox page. + +| Field | Type | Notes | +|---|---|---| +| `key` | string | bounded page-local family key such as `assigned_findings`, `intake_findings`, `stale_operations`, `alert_delivery_failures`, `review_follow_up`; `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention | +| `label` | string | operator-facing section title aligned to the source family | +| `count` | int | visible item count for the current actor and active filters | +| `summary` | string | calm one-line summary of why the family matters | +| `dominant_action_label` | string | primary CTA label, routed to the existing source surface | +| `dominant_action_url` | string | canonical source destination | +| `entries` | list | bounded preview list, not a second queue truth | +| `empty_state` | string | optional local empty explanation when the family is selected explicitly | + +### GovernanceAttentionEntry + +Represents one preview item inside a visible section. + +| Field | Type | Notes | +|---|---|---| +| `family_key` | string | matches the owning `GovernanceInboxSection.key` | +| `source_model` | string | `Finding`, `OperationRun`, `AlertDelivery`, `TenantReview`, or `TenantTriageReview` | +| `source_key` | string | stable source identifier for routing only | +| `tenant_id` | int or null | nullable for workspace-scoped alert or run cases | +| `tenant_label` | string or null | only shown when truthful | +| `headline` | string | concise operator-facing summary | +| `subline` | string or null | bounded reason, owner, or due-state context | +| `urgency_rank` | int | derived sort priority within the family | +| `status_label` | string | reused source-family wording | +| `destination_url` | string | existing canonical route | +| `back_label` | string | return label back to the inbox | + +## Filter State + +### GovernanceInboxFilterState + +| Field | Type | Notes | +|---|---|---| +| `tenant_id` | int or null | optional tenant prefilter; explicit out-of-scope values return `404` | +| `family` | string or null | optional family filter for one visible source family; `stale_operations` remains the canonical filter key for stale or terminal-follow-up operations attention | +| `nav` | array or null | optional shared navigation payload used for return continuity | + +## Ordering Rules + +### Section Order + +1. Assigned findings +2. Findings intake +3. Stale or terminal-follow-up operations +4. Alert-delivery failures +5. Review follow-up + +This order is deliberately explicit and page-local. It is not a new persisted workflow taxonomy. + +### Entry Order + +- Findings-based sections reuse their existing queue ordering. +- Operations reuse the current monitoring-attention ordering exposed by the canonical operations surface. +- Alert-delivery failures order newest unresolved operator-relevant failures first. +- Review follow-up orders explicit follow-up-needed states before changed-since-review states. + +## Relationships + +- One `GovernanceInboxSection` maps to one existing source family. +- One `GovernanceInboxSection` has many derived `GovernanceAttentionEntry` values. +- Each `GovernanceAttentionEntry` points to exactly one existing source record and one existing source destination. +- No derived object owns or mutates source truth. + +## Persistence Rules + +- No new table. +- No new cache. +- No new inbox-specific audit stream. +- No new acknowledged, snoozed, or assigned state. + +## Data Integrity Rules + +- Hidden tenants never contribute to derived section counts or entry previews. +- Family visibility is capability-driven; invisible families do not render empty placeholders. +- Tenantless alert or operation entries must not invent tenant labels. +- Source destinations must stay canonical and existing; the inbox must not invent a parallel detail shell. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/plan.md b/specs/250-decision-governance-inbox/plan.md new file mode 100644 index 00000000..e8576d4e --- /dev/null +++ b/specs/250-decision-governance-inbox/plan.md @@ -0,0 +1,305 @@ +# Implementation Plan: Decision-Based Governance Inbox v1 + +**Branch**: `250-decision-governance-inbox` | **Date**: 2026-04-28 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce one canonical workspace governance inbox inside the existing `/admin` plane by adding a native Filament v5 read-only page that composes existing findings, alerts, stale-operations, and portfolio-triage signals into one decision-first work surface. The page should answer the first operator question quickly, then route into the existing source pages for execution and proof instead of creating a new cross-domain task engine. + +This slice is explicitly composition-only. It does not replace `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail surfaces; it does not add acknowledge, snooze, claim, or assignment mutations; and it does not create persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no new asset bundle is expected for v1. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings, alerts, operations, and review-triage services +**Storage**: PostgreSQL via existing `findings`, `operation_runs`, `alert_deliveries`, `tenant_reviews`, and `tenant_triage_reviews`; no new persistence planned +**Testing**: Pest v4 unit plus feature coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces +**Project Type**: Web application (Laravel monolith with Filament panels) +**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; family previews should be fetched through bounded derived queries rather than one polymorphic persistence layer +**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; avoid new persistence, new workflow states, new task engines, and page-local mutation semantics; reuse source-page routing and action hierarchies +**Scale/Scope**: 1 new admin page, 5 derived source families, 0 new runtime entities, and 1 bounded derived section assembly seam + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` for assigned-findings truth, urgency ordering, and workspace-shell tenant-prefilter behavior. +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` for intake truth, `Needs triage` semantics, and read-first queue behavior. +- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` plus `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` for stale or terminal-follow-up operation attention and canonical run drill-through. +- `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, and the existing alerts cluster for alert-family entry points and delivery-failure truth. +- `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` for review follow-up and triage-state truth. +- `apps/platform/app/Models/Finding.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Models/AlertDelivery.php`, `apps/platform/app/Models/TenantReview.php`, and `apps/platform/app/Models/TenantTriageReview.php` for the source data contracts. +- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php` for source-page routing and return-link continuity. +- `apps/platform/app/Support/OperateHub/OperateHubShell.php`, `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php`, and `apps/platform/app/Support/Filament/TablePaginationProfiles.php` for workspace scope and durable filter state. +- `apps/platform/app/Support/Badges/BadgeRenderer.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php`, `apps/platform/app/Support/Rbac/UiEnforcement.php`, and `apps/platform/app/Support/Rbac/UiTooltips.php` for existing status, action, and capability affordance patterns. +- Likely new implementation files if code work later proceeds: `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, and a bounded support namespace under `apps/platform/app/Support/GovernanceInbox/` only if the page cannot stay readable with page-local composition. + +## UI / Filament & Livewire Fit + +- Implement as a native Filament v5 `Page` in the existing admin plane, not as a new Resource, custom SPA shell, or second monitoring console. +- Keep the inbox read-first and section-based. Each visible family should render one calm summary block plus bounded preview entries and one dominant CTA into the existing source surface. +- Do not model the inbox as a polymorphic table over mixed Eloquent records if that forces a new persisted or generic task abstraction. Section composition over existing family queries is the preferred v1 shape. +- Livewire v4 hydration must preserve tenant and family filter state through public, query-backed, or session-backed state. Do not rely on private properties for any state that must survive a Livewire interaction. +- The new surface is a `Page`, not a globally searchable `Resource`. Existing source resources retain their current search posture. + +## RBAC / Policy Fit + +- Workspace membership remains the first gate. The inbox should not render at all for non-members, and explicit out-of-scope tenant targeting must stay `404`. +- Page access stays capability-derived: the actor must be a workspace member and have visibility to at least one family through the same capability contract the source page already uses. In-scope workspace members who lack every qualifying family capability should receive `403`, not a silent empty shell. +- Findings families reuse tenant capability checks such as `Capabilities::TENANT_FINDINGS_VIEW`, while source mutations like claim or triage continue to enforce `Capabilities::TENANT_FINDINGS_ASSIGN` or `Capabilities::TENANT_FINDINGS_TRIAGE` on their existing surfaces. +- Review follow-up entries reuse `Capabilities::TENANT_REVIEW_VIEW`; any manual follow-up mutation remains on the existing review/triage seam and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE`. +- Alert-family visibility remains workspace-scoped through `Capabilities::ALERTS_VIEW`. +- Operations entries must only appear when the underlying run destination would already be visible through the existing operation-viewer and tenant-entitlement rules. The inbox must not invent a weaker path. + +## Audit / Logging Fit + +- The inbox is read-only and should not create a new page-view audit stream. +- Existing mutation or download actions continue to log on their existing source surfaces. +- The only acceptable additional audit work in v1 would be reuse of existing action IDs on underlying source pages if implementation discovers a missing drill-through event, but the inbox itself should not become a new audit-heavy surface. + +## Data & Query Fit + +- Prefer derived section queries over a generic inbox-item projector or persisted cache. +- The findings sections should reuse the same inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue` rather than duplicating lifecycle logic with new constants. +- The operations section should reuse the same stale or terminal-follow-up classification that already drives the canonical Operations page. Section-level operations CTAs may land on `/admin/operations`, but entry-level operation drill-through should land on the canonical run detail route `/admin/operations/{run}`. +- The alert section should derive from alert-delivery failure truth and the alerts overview, not from alert-rule configuration state. +- The review-follow-up section should derive from `TenantTriageReview` state and existing review register truth, not from a new parallel follow-up model. +- If implementation needs one bounded derived assembly seam, it should remain a page-scoped support helper that normalizes sections and preview entries only. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: governance queues, monitoring drill-through, navigation continuity, badge/status reuse +- **State layers in scope**: page, URL-query +- **Audience modes in scope**: operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third on source pages only +- **Raw/support gating plan**: hidden by default on the inbox page; source pages keep their existing capability-gated disclosure +- **One-primary-action / duplicate-truth control**: each section gets one dominant CTA into an existing source surface; later detail stays off the inbox page +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if implementation introduces a generic task model or local mutations +- **Special surface test profiles**: global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none planned; any new cross-domain workflow state or local mutation must be treated as exception-required drift +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `AlertDeliveryResource`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing source-page action-surface declarations +- **Shared abstractions reused**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `ActionSurfaceDeclaration`, and current source-page query rules +- **New abstraction introduced? why?**: one bounded section or entry assembler may be needed to keep the page readable and deterministic across families, but it must remain derived and page-scoped +- **Why the existing abstraction was sufficient or insufficient**: existing source pages are sufficient for truth and mutation, but insufficient as the first workspace attention surface because they only answer one family each +- **Bounded deviation / spread control**: none planned. If a support namespace is added, it must stay under `Support/GovernanceInbox/`, remain read-only, and not become a cross-product task engine + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, deep-link only +- **Central contract reused**: `OperationRunLinks` and the existing tenantless operation viewer +- **Delegated UX behaviors**: existing canonical run URL resolution and navigation context only +- **Surface-owned behavior kept local**: deciding whether an operation attention entry appears and which existing run destination is primary +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing governance, alerts, operations, and review vocabulary only +- **Neutral platform terms / contracts preserved**: `governance inbox`, `attention`, `operation`, `review follow-up`, `alert delivery failure`, and existing source nouns +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshot truth: PASS. The inbox consumes existing findings, operations, alerts, and review state only. +- Read/write separation: PASS. The page stays read-only and pushes execution back to source surfaces. +- Graph contract path: PASS. No new Graph calls or provider contract work is part of this slice. +- Deterministic capabilities: PASS. The plan reuses existing capability registries and source-page rules. +- Workspace isolation + tenant isolation: PASS. Workspace membership remains a `404` boundary; explicit out-of-scope tenant filters remain `404`; broad listings omit hidden rows. +- RBAC-UX plane separation: PASS. Everything stays inside the admin `/admin` plane. +- Destructive confirmation standard: PASS by non-use. The inbox introduces no destructive or risky action. +- Global search safety: PASS. The new slice is a Page, not a searchable Resource. +- OperationRun and Ops-UX: PASS by deep-link-only reuse. The page starts no run and adds no new run UX state. +- Data minimization: PASS. Default-visible content stays limited to family, urgency, scope, and next action. +- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused `Unit` and `Feature` lanes only. +- Proportionality / no premature abstraction: PASS with one bounded exception. If a section assembler is needed, it remains page-scoped and derived. +- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored attention entity is planned. +- Behavioral state (STATE-001): PASS. The inbox reuses existing source states and does not add a second workflow state family. +- Shared pattern first / UI semantics / Filament native UI: PASS. Existing navigation, badge, and queue semantics are reused. +- Provider boundary (PROV-001): PASS. The slice stays on already-normalized platform seams. +- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new panel is required. +- Asset strategy: PASS. No new asset registration is planned; if implementation later registers an asset anyway, deployment keeps the normal `cd apps/platform && php artisan filament:assets` step. + +**Gate evaluation**: PASS. + +- The inbox stays inside the existing admin plane and current workspace or tenant membership model. +- The page remains a read-only decision hub, not a new execution workflow. +- Existing source pages and services are sufficient for v1 if implementation resists introducing a generic inbox state model. + +**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml)). + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for section and preview assembly plus source-link decisions; Feature for page rendering, authorization, filter behavior, and navigation continuity +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves family assembly without Filament boot cost; feature coverage proves page access, family visibility, tenant-prefilter behavior, and source-page routing on a native page +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse findings, operation runs, alert deliveries, reviews, and triage-review fixtures rather than adding browser setup or generic workflow helpers +- **Expensive defaults or shared helper growth introduced?**: no; any section assembler must stay cheap by default and avoid eager-loading broad unrelated state +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because tenant-prefilter and navigation continuity are part of the page contract +- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page stays read-only, and verify every CTA lands on an existing source surface with hidden tenants omitted from counts and labels +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local unit plus feature increase +- **Review-stop questions**: lane fit, hidden fixture cost, accidental generic workflow helpers, source-page duplication risk +- **Escalation path**: `document-in-feature` for contained assembly-seam notes; `reject-or-split` if implementation introduces a generic task model or local mutation lane +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: routine read-surface and navigation upkeep stays inside this feature unless implementation proves a structural need for a broader workflow engine + +## Rollout & Risk Controls + +- Keep the v1 audience anchored to existing workspace operators and tenant-entitled actors only. +- Treat the page as a routing surface. Do not add local claim, acknowledge, snooze, or follow-up mutation actions during implementation. +- Prefer extending existing source query seams over introducing new persisted or cross-domain workflow state. +- Keep navigation labels aligned with the source pages so the inbox reads as an entry surface, not a replacement shell. +- Validate the page with focused unit and feature coverage before considering any broader dashboard-entry or widget work. + +## Project Structure + +### Documentation (this feature) + +```text +specs/250-decision-governance-inbox/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── governance-inbox.openapi.yaml +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── Findings/ +│ │ │ │ ├── MyFindingsInbox.php +│ │ │ │ └── FindingsIntakeQueue.php +│ │ │ ├── Governance/ +│ │ │ │ └── GovernanceInbox.php # likely new page if implementation proceeds +│ │ │ ├── Monitoring/ +│ │ │ │ ├── Operations.php +│ │ │ │ └── Alerts.php +│ │ │ └── Operations/ +│ │ │ └── TenantlessOperationRunViewer.php +│ │ └── Resources/ +│ │ ├── AlertDeliveryResource.php +│ │ └── TenantReviewResource.php +│ ├── Models/ +│ │ ├── Finding.php +│ │ ├── OperationRun.php +│ │ ├── AlertDelivery.php +│ │ ├── TenantReview.php +│ │ └── TenantTriageReview.php +│ ├── Services/ +│ │ ├── PortfolioTriage/TenantTriageReviewService.php +│ │ └── TenantReviews/TenantReviewRegisterService.php +│ ├── Support/ +│ │ ├── Badges/ +│ │ ├── Filament/ +│ │ ├── GovernanceInbox/ # only if a bounded support seam is required +│ │ ├── Navigation/ +│ │ ├── OperateHub/ +│ │ ├── OperationRunLinks.php +│ │ ├── Rbac/ +│ │ └── Ui/ActionSurface/ +│ └── Policies/ +├── bootstrap/providers.php +├── resources/views/filament/pages/governance/ # likely new page view if implementation proceeds +└── tests/ + ├── Feature/Governance/ + └── Unit/Support/GovernanceInbox/ +``` + +**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching view, and reuse existing source-page routing, RBAC, and status semantics rather than creating a separate workflow subsystem. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 - bounded section or entry assembler | one page still needs deterministic cross-family section ordering and source-surface links | inline page composition alone risks duplicated ordering rules and unreadable page code once five families are involved | + +## Proportionality Review + +- **Current operator problem**: operators cannot decide what needs attention first from one workspace surface despite the repo already having real findings, alerts, operations, and review-follow-up truth. +- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act. +- **Narrowest correct implementation**: add one read-only workspace inbox page over existing source-page queries and routing seams, with at most one bounded derived section or entry assembly helper. +- **Ownership cost created**: one page, one view, one bounded derived assembly seam, and focused unit plus feature coverage. +- **Alternative intentionally rejected**: a persisted inbox-item table or generic task engine was rejected because it adds durable workflow truth before the read-only decision surface is proven. +- **Release truth**: current-release workflow compression, not future workboard preparation. + +## Phase 0 — Research (output: research.md) + +Research resolved the remaining implementation-shaping decisions: + +- choose a section-based composition page over a polymorphic task table or persisted queue +- reuse findings queue semantics from `MyFindingsInbox` and `FindingsIntakeQueue` +- reuse stale or terminal-follow-up operation semantics from `Operations` +- treat alert-delivery failures as the narrow alert-family slice for v1 instead of alert-rule configuration +- reuse `TenantTriageReview` follow-up truth for review-family attention +- rely on `CanonicalNavigationContext` and `OperationRunLinks` for drill-through continuity + +**Output**: [research.md](research.md) + +## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md) + +Design artifacts capture the narrow implementation shape: + +- existing persisted truth reused: findings, operation runs, alert deliveries, tenant reviews, and triage reviews +- new code-owned truth limited to derived inbox sections and preview entries only +- conceptual contract covers one workspace page with optional tenant and family filters plus source-surface links +- quickstart documents the intended slice order, validation commands, and read-only posture + +**Artifacts**: + +- [data-model.md](data-model.md) +- [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml) +- [quickstart.md](quickstart.md) + +## Phase 2 — Planning (for tasks.md) + +Dependency-ordered implementation outline for the later `tasks.md` step: + +1. Add the native governance inbox page shell and read-only view in the admin plane. +2. Resolve the bounded section assembly seam, preferring reuse of source-page query rules over a new workflow subsystem. +3. Add family sections for assigned findings, intake, stale operations, alert-delivery failures, and triage follow-up. +4. Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` for every drill-through path. +5. Add tenant and family filter state with honest empty-state behavior and `404` handling for explicit out-of-scope tenant targeting. +6. Add focused unit and feature tests only; no browser, queue, or heavy-governance family is expected. + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the slice adds no new globally searchable Resource, no destructive inbox action, and no new registered asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are introduced. +- Shared seam outcome: `document-in-feature`. A bounded derived helper was required as `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` because the existing source pages did not expose a reusable cross-family inbox API. The seam stayed page-scoped and read-only; no persisted inbox state or generic workflow engine was introduced. +- Source CTA outcome: PASS. Assigned findings route to `MyFindingsInbox` and tenant finding detail, intake routes to `FindingsIntakeQueue` and tenant finding detail, operations route through `OperationRunLinks` into the canonical tenantless monitoring detail, alerts route to `AlertDeliveryResource` index or view, and review follow-up routes into the existing tenant review or customer review surfaces. The inbox page itself remains mutation-free. +- Filter and authorization outcome: PASS. Workspace membership remains the first gate, explicit out-of-scope tenant filters still resolve as `404`, in-scope members with no visible families still receive `403`, and tenant or family filters stay query-only and capability-safe. +- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`. +- Smoke evidence: integrated-browser smoke on `http://localhost/admin/governance/inbox` passed in an authenticated workspace session. The inbox loaded successfully, the operations-family CTA opened the canonical `/admin/operations` route with `problemClass=terminal_follow_up` plus the shared `nav` payload, the monitoring page rendered a visible `Back to governance inbox` control, and that return link brought the session back to `/admin/governance/inbox`. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Review outcome class: `acceptable-special-case`. +- Workflow outcome: `keep`. +- Exception note: none beyond the bounded section-builder seam already recorded above. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/quickstart.md b/specs/250-decision-governance-inbox/quickstart.md new file mode 100644 index 00000000..67464ec8 --- /dev/null +++ b/specs/250-decision-governance-inbox/quickstart.md @@ -0,0 +1,65 @@ +# Quickstart: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Purpose + +This quickstart captures the smallest intended implementation and validation path for the governance inbox slice. It is preparation-only guidance for later implementation work. + +## Planned Implementation Shape + +1. Add one native Filament page at `/admin/governance/inbox`. +2. Compose five bounded source families from existing repo truth: + - assigned findings + - findings intake + - stale or terminal-follow-up operations + - alert-delivery failures + - review follow-up +3. Keep the page read-only and route every action into an existing source surface. +4. Keep tenant and family filters query-safe and workspace-safe. + +## Planned Validation Commands + +Run the minimum proving commands once implementation exists: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Review Checklist For Later Implementation + +- Open `/admin/governance/inbox` as a workspace operator with at least two visible signal families. +- Verify the page stays read-only and does not offer claim, snooze, acknowledge, assign, or triage mutation controls. +- Verify a tenant-scoped launch prefilters the page to the current tenant. +- Verify explicit out-of-scope `tenant_id` query input returns `404`. +- Verify each visible section opens an existing source surface and preserves a back-link or source context. + +## Guardrails To Preserve + +- No new persisted inbox-item table. +- No generic cross-domain task engine. +- No browser-only validation requirement by default. +- No raw-support or debug detail rendered on the inbox page. + +## Close-Out Target For Later Implementation + +Record the final outcome in `Guardrail / Exception / Smoke Coverage` once implementation happens, including: + +- whether a bounded `Support/GovernanceInbox/` seam was actually needed +- whether all source CTAs stayed on existing canonical surfaces +- whether any contained drift resolved as `document-in-feature` +- the final proof outcome from the focused unit and feature validation commands + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. The implemented slice stayed on the existing Filament v5 / Livewire v4 admin plane, kept provider registration untouched in `apps/platform/bootstrap/providers.php`, introduced no destructive inbox action, and added no new registered asset bundle. +- Bounded seam result: `document-in-feature`. The final implementation required `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` as a derived page-scoped assembler because the current source pages did not expose a reusable cross-family API. +- Source-surface result: PASS. All dominant section CTAs and preview-entry links stayed on existing findings, operations, alerts, and review surfaces; no inbox-local mutation lane or detail shell was added. +- Focused proof result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Smoke result: PASS. Manual integrated-browser smoke confirmed `/admin/governance/inbox` loads in workspace context, the operations CTA navigates to the canonical monitoring route with return context, and the explicit back link returns to the inbox. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/research.md b/specs/250-decision-governance-inbox/research.md new file mode 100644 index 00000000..152d75cd --- /dev/null +++ b/specs/250-decision-governance-inbox/research.md @@ -0,0 +1,104 @@ +# Research: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Decision Summary + +The repo already contains the underlying governance attention signals. The missing product slice is not another source page or another workflow state, but one bounded decision-first page that composes the existing source seams into a calm workspace starting point. + +## Key Decisions + +### 1. Use section-based composition, not a generic task engine + +- **Decision**: Build the inbox as one read-only Filament page with bounded family sections and preview entries. +- **Why**: A polymorphic table or persisted inbox-item model would import a second workflow truth before the first read-only operator surface is proven. +- **Repo truth**: Findings, operations, alerts, and review follow-up already have their own truthful pages and models. + +### 2. Reuse findings queue semantics directly + +- **Decision**: The assigned-findings and intake sections should reuse the inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue`. +- **Why**: Those pages already codify open-status filtering, tenant entitlement, urgency ordering, and calm empty states. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + - `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` + +### 3. Use stale or terminal-follow-up operations as the operations-family signal + +- **Decision**: The operations section should derive from the same stale or follow-up attention rules already exposed on the canonical `Operations` page. +- **Why**: The repo already has a canonical operations monitoring surface and run-detail route; the inbox should route into it instead of inventing a second operations diagnostic layer. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Support/OperationRunLinks.php` + +### 4. Keep the alert-family slice narrow: failed alert deliveries, not alert-rule config + +- **Decision**: The alerts section should surface delivery failures or similar operator-attention alert outcomes, not alert-rule configuration. +- **Why**: Delivery failure is the actionable alerting gap that belongs in an attention inbox. Alert-rule editing stays a configuration workflow on its existing surfaces. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Monitoring/Alerts.php` + - `apps/platform/app/Filament/Resources/AlertDeliveryResource.php` + - `apps/platform/app/Models/AlertDelivery.php` + +### 5. Use triage-review follow-up as the review-family signal + +- **Decision**: The review section should derive from `TenantTriageReview` states such as `follow_up_needed` and changed-since-review semantics. +- **Why**: The repo already distinguishes review follow-up from the underlying review artifact; the inbox should reuse that distinction rather than invent a second attention reason model. +- **Source seams**: + - `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` + - `apps/platform/app/Models/TenantTriageReview.php` + - `apps/platform/app/Filament/Resources/TenantReviewResource.php` + +### 6. Preserve navigation continuity through shared context helpers + +- **Decision**: Every section and preview entry should use existing navigation helpers for back links and canonical destinations. +- **Why**: The inbox only reduces attention load if it preserves return context instead of opening detached utility flows. +- **Source seams**: + - `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` + - `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` + +### 7. Keep the inbox read-only in v1 + +- **Decision**: No claim, snooze, acknowledge, assign, or triage mutations are introduced on the inbox page. +- **Why**: Those mutations already belong to source surfaces and would force the inbox to become a second workflow owner. +- **Result**: The inbox remains a decision hub, not an execution surface. + +## Access Model Decision + +- Workspace membership remains the first gate. +- The page only needs to exist for actors who can already see at least one family. +- Rows and counts must stay family-specific: + - findings sections require `Capabilities::TENANT_FINDINGS_VIEW` + - review follow-up requires `Capabilities::TENANT_REVIEW_VIEW` + - alert-family sections require `Capabilities::ALERTS_VIEW` + - source mutations remain on source pages with their existing capabilities +- Explicit out-of-scope `tenant_id` inputs return `404`. + +## Rejected Alternatives + +### Rejected: persisted inbox-item table + +- **Reason**: adds durable workflow truth, migration cost, audit burden, and new lifecycle semantics before the read-only composition page is proven. + +### Rejected: generic cross-domain work-item abstraction + +- **Reason**: over-generalizes five concrete families into a second vocabulary and invites a platform-level task framework that current-release truth does not require. + +### Rejected: extend one existing page instead of adding a canonical inbox + +- **Reason**: no single existing page can truthfully host all five families without becoming the wrong domain owner. + +## Implications For Implementation + +- Prefer one bounded `Support/GovernanceInbox/` seam only if page-local composition becomes unreadable. +- Keep source-family labels close to existing UI copy to avoid a second UX language. +- Keep empty states honest: + - tenant-prefilter-hidden attention -> `Clear tenant filter` + - globally calm -> one neutral workspace return CTA +- Do not add page-level audit noise for mere page views. + +## Planning Outcome + +The smallest viable implementation slice is one new read-only workspace page that reuses existing source-page queries, existing navigation helpers, and existing capability semantics. No new persistence or mutation lane is justified. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/spec.md b/specs/250-decision-governance-inbox/spec.md new file mode 100644 index 00000000..4d46a6fc --- /dev/null +++ b/specs/250-decision-governance-inbox/spec.md @@ -0,0 +1,294 @@ +# Feature Specification: Decision-Based Governance Inbox v1 + +**Feature Branch**: `250-decision-governance-inbox` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Select the next best open spec candidate from roadmap and spec-candidates, then prepare a narrow repo-grounded Spec Kit package for a decision-oriented governance inbox that consolidates existing findings, alerts, stale operations, and portfolio triage signals without implementing application code." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has real findings queues, alerting, operations monitoring, and portfolio triage state, but operators still have to reconstruct what needs attention by moving across several surfaces before they can decide what to open next. +- **Today's failure**: The product still behaves like an entity-first console instead of a decision-first work surface. Operators can miss stale operations, alert-delivery failures, review follow-up, or unassigned findings because each signal family lives on its own page. +- **User-visible improvement**: One canonical workspace inbox shows the most important governance attention from more than one existing signal family and routes the operator straight into the right existing execution or evidence surface. +- **Smallest enterprise-capable version**: One new read-first workspace page under `/admin` that aggregates existing assigned-findings, findings-intake, stale-operations, alert-delivery-failure, and review-follow-up signals into bounded sections with calm summaries and direct links into existing source pages. No new mutation lane ships on the inbox itself. +- **Explicit non-goals**: No replacement of `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail pages; no new persisted inbox-item table; no generic cross-domain task engine; no new acknowledge, snooze, or assignment state; no customer-facing inbox; no AI recommendations; no cross-workspace workboard. +- **Permanent complexity imported**: One canonical inbox page, one bounded derived section or entry assembly seam, one cross-family priority order, query-state handling for tenant and family filters, and focused unit plus feature coverage. +- **Why now**: The implementation ledger marks the missing decision inbox as a P0 workflow blocker immediately after Customer Review Workspace, while `spec-candidates.md` still lists it as P1. This package follows the stronger ledger urgency because the repo already has the underlying signal families, so the next product value is compression of operator attention, not another isolated source page. +- **Why not local**: Extending only `My Findings`, only `Operations`, or only `Alerts` would keep the current multi-page reconstruction problem intact and would not provide one truthful starting point for workspace attention. +- **Approval class**: Workflow Compression +- **Red flags triggered**: One mild `many surfaces` flag because the page composes several existing signal families. Defense: the slice stays read-only, introduces no new persistence, and explicitly reuses underlying source pages instead of replacing them. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - new canonical workspace route `/admin/governance/inbox` + - existing `/admin/findings/my-work` + - existing `/admin/findings/intake` + - existing `/admin/operations` + - existing alerts cluster routes under `/admin/alerts/*` + - existing `/admin/reviews` and tenant-scoped review detail routes used for triage follow-up drill-through +- **Data Ownership**: + - tenant-owned `Finding`, `OperationRun`, `TenantReview`, and `TenantTriageReview` remain the only source of truth for their respective sections + - workspace-scoped `AlertDelivery`, `AlertRule`, and `AlertDestination` remain the alerting source of truth + - the governance inbox is a derived read surface only; it introduces no new table, cache, mirror entity, or workflow state +- **RBAC**: + - workspace membership remains the first access boundary for the inbox page + - page entry is allowed only when the actor is a workspace member and at least one source family is visible through existing capabilities + - non-members and explicit out-of-scope tenant targeting remain `404` deny-as-not-found boundaries + - in-scope workspace members who lack every qualifying source-family capability receive `403` instead of a silent empty shell + - assigned-findings and intake sections only include tenants where the actor has `Capabilities::TENANT_FINDINGS_VIEW` + - triage follow-up rows only include tenants where the actor has `Capabilities::TENANT_REVIEW_VIEW`; any follow-up mutation remains on the existing review surface and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE` + - alert-delivery failure sections only appear for actors who can access workspace alerts through `Capabilities::ALERTS_VIEW` + - operation-attention rows only appear when the actor could already open the underlying operation destination through the existing run and tenant entitlement rules + - the inbox itself is read-first; source-surface mutations such as claim, triage, acknowledge, or follow-up continue to enforce their existing server-side Gates or Policies + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped findings, review, or tenant dashboard surface, the inbox prefilters to that tenant while keeping the family filter on `All attention`. Operators may clear only the tenant prefilter to return to all visible attention across the workspace. +- **Explicit entitlement checks preventing cross-tenant leakage**: Explicit `tenant_id` inputs outside the actor's visible scope resolve as not found. Broad workspace listings silently omit inaccessible tenants, hidden signal families, and blocked drill-through targets from counts, labels, and empty-state hints. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation entry points, dashboard signals/cards, status messaging, action links, monitoring and governance drill-through, and badge semantics +- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing alert, findings, and review source pages +- **Existing pattern(s) to extend**: native Filament workspace pages with tenant-prefilter state, existing queue summaries, `OperateHubShell` scope handling, `CanonicalNavigationContext` back-link continuity, and `ActionSurfaceDeclaration` documentation +- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `CanonicalAdminTenantFilterState`, and the existing source-page query rules from `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, and review/triage services +- **Why the existing shared path is sufficient or insufficient**: Existing source pages already own the underlying truth and mutation semantics, but they are insufficient as a first decision surface because they only answer one family at a time. The inbox should compose those seams, not replace them. +- **Allowed deviation and why**: none planned. If implementation needs a bounded local section assembler, it must remain derived, page-scoped, and must not become a cross-product task framework. +- **Consistency impact**: Priority language, empty-state language, badge semantics, and drill-through labels must stay aligned with the existing source surfaces so the inbox feels like a routing layer over product truth rather than a parallel UX language. +- **Review focus**: Reviewers must block any implementation that duplicates local claim, acknowledge, triage, or stale-run mutation semantics on the inbox page or invents a second cross-domain workflow state. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes, deep-link only +- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` plus the existing tenantless operation viewer path +- **Delegated start/completion UX behaviors**: canonical `Open operation` / run-detail URL resolution and existing operation-context navigation only; no new queued toast, run-enqueued event, or terminal-notification behavior is introduced +- **Local surface-owned behavior that remains**: the inbox only decides whether an operations attention section is shown and which existing run link is primary for that entry +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## 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`)* + +N/A - no shared provider/platform boundary is widened. The inbox consumes already-normalized governance, alerts, operations, and review seams without introducing new provider-specific contracts. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Governance inbox page | yes | Native Filament page plus shared primitives | governance queues, monitoring drill-through, navigation continuity, badge/status reuse | page, URL-query | no | One new canonical read-only decision surface; source pages remain authoritative | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Governance inbox page | Primary Decision Surface | Operator opens the workspace and decides which existing governance surface needs attention first | visible attention by family, tenant scope, urgency, count, and dominant next action | full source detail, operation detail, alerts context, and review/finding evidence only after opening the source surface | Primary because it becomes the first workspace attention surface across more than one signal family | Follows the operator question `what needs attention now?` before the entity-specific question `what does this record contain?` | Replaces multi-page search across findings, alerts, operations, and review follow-up with one calm starting point | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Governance inbox page | operator-MSP | family summary, top attention entries, urgency cues, tenant scope, and direct next action into the existing source surface | source-specific detail remains on `My Findings`, `Findings intake`, `Operations`, `Alerts`, and review surfaces | raw payloads, alert body details, operation diagnostics, and evidence payloads stay on existing source pages and remain capability-gated there | `Open attention source` per section or entry | raw/support detail is not rendered on the inbox page | the inbox states the decision truth once, then relies on source pages for proof rather than re-explaining the same blocker in parallel blocks | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the existing source surface for the highest-priority attention family or entry | explicit section or preview-entry CTA into the underlying source surface | forbidden | section footers or preview-entry links only | none | `/admin/governance/inbox` | existing source-specific routes, including `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/operations/{run}` for entry-level operations drill-through, alerts cluster routes, and review routes | active workspace, optional tenant prefilter, family filter | Governance inbox | which attention family needs action now and where the operator should go next | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Workspace operator / MSP operator | Decide which existing governance surface should be opened next | Workspace decision hub | What needs attention right now across my visible governance surfaces, and where should I go to act? | section counts, top items, tenant label when applicable, urgency cues, family label, and source CTA | source-specific reason detail, evidence, alert metadata, and full operation diagnostics remain on source surfaces | urgency, source family, tenant scope, follow-up state, delivery failure state, stale/terminal attention state | none on the inbox page itself | Open my findings, Open intake, Open operation, Open alerts, Open review follow-up | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded derived section or entry assembly seam may be needed to compose multi-family attention into one page +- **New enum/state/reason family?**: no persisted family; any family keys remain local derived page constants only +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: operators cannot answer `what needs attention now?` from one workspace surface, even though the repo already has real findings, alerts, operations, and review-follow-up truth +- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act +- **Narrowest correct implementation**: one read-only workspace page that derives its sections from existing source-page query semantics and routes operators into the existing source surfaces +- **Ownership cost**: one new page, one bounded derived assembly seam, tenant and family query-state handling, and focused unit plus feature coverage +- **Alternative intentionally rejected**: a generic cross-product task engine or persisted inbox-item table was rejected because it would import new workflow truth before the read-only decision surface is proven +- **Release truth**: current-release workflow compression, not future-release workboard infrastructure + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves attention-family assembly, ordering, and source-link decisions cheaply; focused feature coverage proves workspace membership, per-family visibility, tenant-prefilter behavior, and navigation continuity on a native Filament page. Browser coverage is not the narrowest honest proof for this slice. +- **New or expanded test families**: one focused `GovernanceInbox` feature family and one focused `Unit/Support/GovernanceInbox` family +- **Fixture / helper cost impact**: moderate; tests need visible and hidden tenants, findings in assigned and intake states, stale or terminal-follow-up runs, failed alert deliveries, and triage review states, but should reuse existing factories and avoid browser or heavy-governance setup +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient once explicit tests prove tenant-prefilter state, family omission, and source-surface navigation context +- **Reviewer handoff**: reviewers must confirm that hidden tenant signals never leak into counts or labels, the page stays read-only, and every CTA lands on an existing source surface rather than a new local mutation lane +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` + +## Scope Boundaries + +### In Scope + +- one canonical workspace-level governance inbox page in the existing admin plane +- bounded attention sections for assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up signals +- calm counts and top-entry previews per visible family +- existing source-surface links with preserved navigation context +- tenant and family filters with honest empty-state behavior + +### Non-Goals + +- replacing or hiding the existing source pages that already own findings, operations, alerts, or review state +- new acknowledge, snooze, claim, triage, or assign actions on the inbox page +- a new persisted inbox-item or work-state table +- cross-workspace or customer-facing inboxes +- AI prioritization, autonomous routing, or recommendation logic +- raw-support or debug detail on the inbox page itself + +## Assumptions + +- existing source pages already expose enough truth to derive section counts and top previews without introducing a second workflow state +- alert-delivery failures are the narrowest alert-family attention slice for v1; alert-rule configuration remains secondary +- existing `CanonicalNavigationContext` and `OperationRunLinks` seams are sufficient for return-link continuity +- the page can stay useful even when only a subset of families is visible for the current actor + +## Risks + +- a single mixed attention list could tempt implementation toward a new generic task model; this must be resisted in favor of bounded section composition +- some operations or alert items may be workspace-scoped while other families are tenant-scoped, which increases the chance of misleading empty states if filter logic is not explicit +- if the page tries to surface too much source detail, it can become a duplicate of the underlying pages instead of a decision hub + +## Follow-up Candidates + +- bounded acknowledge or snooze semantics once a real cross-family attention state exists in the product +- dashboard or workspace-overview entry signals into the governance inbox after the canonical page is proven +- a broader decision-based operating system slice only after the first read-only inbox is adopted successfully + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Multi-Family Attention In One Place (Priority: P1) + +As a workspace operator, I want one inbox that shows the most important governance attention across more than one signal family so I can decide where to work next without scanning multiple pages first. + +**Why this priority**: This is the core missing value. Without a multi-family attention surface, the product still forces page-hopping before any decision can be made. + +**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and triage follow-up. Open the inbox and verify that the page shows more than one visible family with calm counts and top entries. + +**Acceptance Scenarios**: + +1. **Given** the actor has visible assigned findings and stale operations, **When** they open the governance inbox, **Then** both families appear with separate counts, urgency cues, and one dominant source CTA each. +2. **Given** the actor can access only findings and not alerts, **When** they open the governance inbox, **Then** alert sections, labels, and counts do not appear at all. +3. **Given** no visible attention exists in any accessible family, **When** they open the governance inbox, **Then** the page shows one calm empty state and does not imply hidden work exists elsewhere. + +--- + +### User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1) + +As a workspace operator, I want the inbox to route me into the correct existing page with preserved context so the inbox stays a decision hub and not a duplicate execution surface. + +**Why this priority**: The page only reduces attention load if the next click is obvious and lands in the existing product truth. + +**Independent Test**: Open the governance inbox, choose one attention entry from findings, operations, and review follow-up, and verify that each CTA lands on the correct existing destination with back-link or context continuity preserved. + +**Acceptance Scenarios**: + +1. **Given** an assigned-findings section is visible, **When** the actor chooses its dominant action, **Then** the destination opens the existing `My Findings` or tenant finding detail surface instead of a new local inbox detail shell. +2. **Given** an operations attention entry is visible, **When** the actor opens it, **Then** the destination uses the canonical operation URL path and preserves a return path back to the inbox. +3. **Given** a review follow-up section is visible, **When** the actor opens it, **Then** the destination lands on the existing review or triage surface rather than a duplicate summary on the inbox page. + +--- + +### User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2) + +As a workspace operator, I want the governance inbox to respect tenant context and family filters without leaking hidden tenants, hidden families, or inaccessible records. + +**Why this priority**: A decision hub is dangerous if it implies missing or hidden work incorrectly or if it leaks cross-tenant state through filter labels or empty-state hints. + +**Independent Test**: Open the inbox with an active tenant context, with an explicit family filter, and with an inaccessible tenant query parameter. Verify the resulting rows, counts, and empty states are truthful and capability-safe. + +**Acceptance Scenarios**: + +1. **Given** an active tenant context exists, **When** the actor opens the governance inbox, **Then** the page prefilters to that tenant and allows the actor to clear only the tenant prefilter back to all visible attention. +2. **Given** a `tenant_id` query parameter references a tenant outside the actor's scope, **When** the governance inbox loads, **Then** the request resolves as not found instead of rendering an empty or hinting state. +3. **Given** the actor applies a family filter for one accessible family, **When** the page renders, **Then** counts, previews, and empty-state copy describe only that visible family and do not mention hidden families. + +### Edge Cases + +- a single tenant may contribute more than one visible family at once; the inbox must keep those families separate instead of inventing a merged workflow state +- alert-delivery failure rows may be workspace-scoped and tenantless; the page must not fabricate tenant labels or tenant-only actions for them +- an operation run may remain in the workspace database after the actor loses tenant entitlement; the inbox must omit it rather than leak stale references +- a tenant prefilter can hide otherwise visible attention in other tenants; the empty state must explain the tenant boundary honestly before claiming the workspace is calm + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no Microsoft Graph call, no new queue start, no new `OperationRun`, and no new persisted truth. It adds one derived read-only decision surface over existing findings, alerts, operations, and review-triage truth. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The inbox must stay derived. It must not create a new task engine, persisted attention table, or cross-domain workflow state. Any new assembly seam must remain bounded to page composition and reuse existing source-state semantics. + +**Constitution alignment (XCUT-001):** The inbox must extend existing shared navigation, badge, and source-surface patterns rather than inventing a parallel interaction family for claim, acknowledge, stale-run handling, or review follow-up. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The inbox must remain decision-first. Default-visible content is family, urgency, scope, and next action only. Diagnostics, evidence, and raw-support details stay on the source pages. + +**Constitution alignment (TEST-GOV-001):** The implementation must stay in focused `Unit` and `Feature` lanes. No browser or heavy-governance family is justified by default for this slice. + +**Constitution alignment (RBAC-UX):** Workspace membership remains the first boundary. Explicit out-of-scope tenant filters return `404`. Once workspace membership is established, missing per-family capabilities continue to suppress rows or source actions instead of leaking inaccessible truth. + +**Constitution alignment (RBAC-UX - page access):** Non-members and out-of-scope tenant targeting return `404`, while in-scope workspace members who lack every qualifying family capability receive `403` on page access. + +### Functional Requirements + +- **FR-001**: The system MUST provide a canonical governance inbox at `/admin/governance/inbox` inside the existing admin plane. +- **FR-002**: The inbox MUST aggregate visible attention from more than one underlying signal family using existing product truth rather than a new persisted workflow state. +- **FR-003**: The first supported attention families in v1 MUST be assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up. +- **FR-004**: The inbox MUST remain read-first. It MUST route to existing source surfaces for claim, triage, operation review, alert drill-through, or review follow-up instead of re-implementing those mutations locally. +- **FR-005**: The inbox MUST expose family counts, top attention previews, tenant scope when applicable, and one dominant source CTA per visible section. +- **FR-006**: The inbox MUST support an optional tenant prefilter and optional family filter. When tenant context is active, the tenant prefilter is applied by default. +- **FR-007**: The inbox MUST omit inaccessible tenants, inaccessible families, and inaccessible source actions from counts, labels, empty-state hints, and preview content. +- **FR-008**: If the actor explicitly targets an out-of-scope tenant through query state, the inbox MUST return `404` deny-as-not-found semantics. +- **FR-009**: Operation-related entries MUST reuse canonical run URLs and existing operation lifecycle semantics instead of inventing local stale-run logic. +- **FR-010**: Alert-related entries MUST derive from existing alert delivery or alert overview truth and MUST NOT duplicate alert-rule configuration state as work items. +- **FR-011**: Review-follow-up entries MUST derive from existing tenant review and triage-review truth and MUST NOT create a second follow-up state family. +- **FR-012**: The inbox MUST NOT introduce a new globally searchable resource, a new panel, or a new asset bundle for v1. +- **FR-013**: The inbox MUST enforce `404` for non-members and explicit out-of-scope tenant targeting, and `403` for in-scope workspace members who lack any qualifying visible-family capability. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` | `Clear tenant filter` only when a tenant prefilter is active | explicit section and preview-entry CTA into existing source surfaces; no local detail shell | none | none | `Clear tenant filter` when the tenant filter alone hides attention; otherwise `Open workspace dashboard` | n/a | n/a | no direct audit; page stays read-only | Action Surface Contract stays satisfied because the page has one dominant navigation goal and no local mutation lane | + +### Key Entities *(include if feature involves data)* + +- **Governance inbox section**: A derived grouping for one source family that carries a title, visible count, dominant next action, and top previews. +- **Governance attention entry**: A derived preview item that points to one existing source surface and carries only the minimal status, scope, and urgency information needed for the next click. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an operator can determine within 15 seconds whether assigned findings, intake findings, stale operations, alert-delivery failures, or review follow-up require attention from one page. +- **SC-002**: 100% of covered automated tests show that hidden tenants and hidden families do not leak into counts, labels, or empty-state hints. +- **SC-003**: 100% of covered automated tests show that each visible family routes to an existing canonical source surface rather than a new local mutation or detail shell. +- **SC-004**: With seeded workspace data from at least two signal families, the inbox can show both on one page without introducing a new persisted workflow state. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/tasks.md b/specs/250-decision-governance-inbox/tasks.md new file mode 100644 index 00000000..189fd67d --- /dev/null +++ b/specs/250-decision-governance-inbox/tasks.md @@ -0,0 +1,173 @@ +--- + +description: "Task list for Decision-Based Governance Inbox v1" + +--- + +# Tasks: Decision-Based Governance Inbox v1 + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` + +**Tests**: Required (Pest). Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature artifacts. +**Operations**: The inbox introduces no new `OperationRun`, queue, or result ledger. It only deep-links into existing run detail surfaces through the shared operation-link contract. +**RBAC**: Workspace membership remains the first gate. Explicit out-of-scope tenant filters remain `404`. Source-family rows and source-family destinations stay capability-gated through existing registries and policies. +**Organization**: Tasks are grouped by user story so the multi-family read surface, source-surface routing, and filter-safety behavior remain independently testable after the shared foundation is in place. + +## Test Governance Checklist + +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay under `apps/platform/tests/Unit/Support/GovernanceInbox/` and `apps/platform/tests/Feature/Governance/` only; no browser or heavy-governance lane is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; do not add a generic workflow fixture or seeded inbox-item state. +- [x] Planned validation commands cover section assembly, page access, and navigation continuity without pulling in unrelated lane cost. +- [x] The declared surface test profile remains `global-context-shell` because tenant-prefilter and navigation continuity are part of the page contract. +- [x] Any bounded assembly-seam drift resolves as `document-in-feature` unless implementation proves a structural workflow-engine need. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded slice, source seams, and reviewer stop conditions before runtime implementation begins. + +- [x] T001 Review the bounded slice, explicit non-goals, and guardrail expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` +- [x] T002 [P] Review the implementation-shaping decisions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` +- [x] T003 [P] Confirm the source-page seams that must remain authoritative: `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the read-only page shell, authorization boundaries, and bounded assembly seam that every user story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add focused authorization coverage for workspace membership, explicit out-of-scope tenant-prefilter `404` behavior, in-scope member `403` behavior when no qualifying family capability exists, and family-level omission rules in `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` +- [x] T005 Create the native governance inbox page shell and Blade view in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, keeping the surface read-only and inside the admin plane +- [x] T006 Resolve the section-assembly seam by reusing existing source-page query rules first; only if the page becomes unreadable, add a bounded helper under `apps/platform/app/Support/GovernanceInbox/` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` +- [x] T007 [P] Thread tenant and family filter state plus navigation context through `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, reusing `CanonicalNavigationContext` and `CanonicalAdminTenantFilterState` rather than introducing a page-local state system + +**Checkpoint**: The inbox page shell, page access rules, and bounded assembly decision exist. User-story work can now proceed independently. + +--- + +## Phase 3: User Story 1 - See Multi-Family Attention In One Place (Priority: P1) MVP + +**Goal**: Let a workspace operator see more than one visible signal family from one decision-first page without introducing a second workflow state. + +**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and review follow-up, then verify the inbox shows calm section summaries and top previews from more than one family. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add unit coverage for derived section and preview-entry assembly in `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` +- [x] T009 [P] [US1] Add feature coverage for multi-family page rendering, calm counts, and honest global empty-state behavior in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` + +### Implementation for User Story 1 + +- [x] T010 [US1] Derive the assigned-findings and intake sections from the existing query semantics in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` without introducing new workflow-state constants +- [x] T011 [US1] Derive the operations and alerts sections from `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, and `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, keeping the alert-family slice focused on delivery-failure attention rather than alert-rule configuration +- [x] T012 [US1] Derive the review follow-up section from `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Models/TenantTriageReview.php`, and the existing review register truth, then render all visible sections on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` + +**Checkpoint**: User Story 1 is independently functional when more than one visible family can appear on the inbox page without new persisted workflow state. + +--- + +## Phase 4: User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1) + +**Goal**: Route every visible section and preview entry into the correct existing source surface so the inbox stays a decision hub rather than becoming a duplicate execution shell. + +**Independent Test**: Open the inbox, use findings, operations, alerts, and review-follow-up CTAs, and verify each destination is an existing canonical source route with preserved return or source context. + +### Tests for User Story 2 + +- [x] T013 [P] [US2] Add focused navigation-context coverage for source-surface CTAs and back-link continuity in `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` + +### Implementation for User Story 2 + +- [x] T014 [US2] Route findings and review-follow-up sections through existing source pages using `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and the existing resource URL helpers on `apps/platform/app/Filament/Resources/TenantReviewResource.php` +- [x] T015 [US2] Route operation attention entries through `apps/platform/app/Support/OperationRunLinks.php` and the canonical tenantless operation detail route `/admin/operations/{run}` instead of inventing a new inbox-local detail shell +- [x] T016 [US2] Keep the inbox read-only by ensuring claim, triage, acknowledge, snooze, and follow-up mutations remain on their source surfaces; if any source surface needs small back-link hardening, change the smallest source page rather than adding local mutations on `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` + +**Checkpoint**: User Story 2 is independently functional when every visible CTA lands on an existing source surface with preserved context and the inbox still owns no mutations. + +--- + +## Phase 5: User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2) + +**Goal**: Keep tenant and family filtering honest so the inbox never leaks hidden tenants, hidden families, or inaccessible source destinations. + +**Independent Test**: Load the inbox with an active tenant context, a family filter, and an explicit hidden tenant query parameter, then verify the resulting counts, labels, and empty states are truthful. + +### Tests for User Story 3 + +- [x] T017 [P] [US3] Extend feature coverage for tenant-prefilter state, family filters, hidden-family omission, and tenant-specific empty-state branches in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` + +### Implementation for User Story 3 + +- [x] T018 [US3] Add family and tenant filter handling to `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, keeping active tenant context durable and clearable without inventing a second filter persistence system +- [x] T019 [US3] Ensure hidden tenants and hidden families never contribute to section counts, preview labels, or empty-state hints, and keep tenantless alert or operations entries truthful when rendered on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` + +**Checkpoint**: User Story 3 is independently functional when tenant and family filters remain capability-safe and globally honest. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation, formatting, and reviewer close-out without widening scope. + +- [x] T020 [P] Run the focused unit validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` +- [x] T021 [P] Run the focused page and authorization validation commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` and `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` +- [x] T022 [P] Run the focused navigation-context validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` +- [x] T023 Run dirty-only Pint for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` +- [x] T024 Record the final `Guardrail / Exception / Smoke Coverage` close-out, including whether a bounded `Support/GovernanceInbox/` seam was needed and whether any contained drift resolved as `document-in-feature`, in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user-story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the first independently valuable slice. +- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because it reuses the same page and view files. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because filter and empty-state behavior depend on the final visible sections. +- **Phase 6 (Polish)**: depends on the stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently shippable as the minimal read-only decision surface once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page composition and routing files are shared hotspots. +- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because family and tenant filters depend on the visible section set. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap. +- Finish shared query or routing reuse before widening the page view. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- First shippable slice = **Phase 2 + User Story 1 + User Story 2**. That delivers the canonical decision-first inbox page with the required multi-family attention surface and the required routing into existing source surfaces. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate the multi-family read surface. +3. Deliver US2 and validate that all CTAs land on existing source surfaces. +4. Deliver US3 and validate filter honesty plus `404` handling. +5. Finish with Phase 6 validation, formatting, and close-out recording. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize unit and feature test authoring inside each story first. +3. Serialize merges touching `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, because they are the main conflict hotspots for this slice. + +## Notes + +- [P] tasks should stay on different files or clearly isolated seams. +- Each story remains independently testable, but the first shippable slice includes both US1 and US2 because routing into existing source surfaces is part of the required product contract. +- Re-run the narrowest relevant Pest command after each story checkpoint before moving forward. +- Stop at each checkpoint if the page starts drifting toward a generic workflow engine or local mutation lane. \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/checklists/requirements.md b/specs/251-commercial-entitlements-billing-state/checklists/requirements.md new file mode 100644 index 00000000..c2fdb0ee --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Commercial Entitlements and Billing-State Maturity + +**Purpose**: Validate specification completeness and readiness before planning or implementation. +**Created**: 2026-04-28 +**Feature**: [../spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Review Outcome + +- [x] Review outcome class: acceptable-special-case +- [x] Workflow outcome: keep +- [x] Test-governance impact is explicitly recorded in the spec + +## Notes + +- Repo-specific surface names and existing product terms are used to anchor the spec to current truth, but the spec does not prescribe languages, frameworks, APIs, or low-level implementation design. +- No open clarification markers remain. The bounded assumptions are the default `active_paid` resolution for unset workspaces and the distinct `grace` behavior that freezes onboarding expansion without blocking in-scope review-pack starts. +- Implementation close-out keeps the workflow outcome as `keep`. The Livewire browser-smoke finding was fixed inside scope by making workspace route resolution accept Livewire serialized workspace parameters; no follow-up spec is required. diff --git a/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml b/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml new file mode 100644 index 00000000..b1968aaf --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml @@ -0,0 +1,465 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin/System - Workspace Commercial Lifecycle Overlay (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the commercial lifecycle overlay that follows the + existing workspace entitlement substrate from Spec 247. + + NOTE: These routes are implemented as existing Filament pages, resources, + widgets, and Livewire-backed actions. Exact Livewire payload shapes are not + part of this contract. This file captures logical route boundaries, the + system/admin split, and the required 404 / 403 / business-state semantics. +servers: + - url: /admin + - url: /system +paths: + /directory/workspaces/{workspace}: + get: + summary: View read-only workspace commercial lifecycle summary in the system plane + description: | + Renders the existing system directory workspace detail page with the + effective lifecycle state, rationale, affected behavior summary, and the + reused entitlement substrate summary. + parameters: + - $ref: '#/components/parameters/WorkspaceId' + responses: + '200': + description: System workspace detail rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/SystemWorkspaceCommercialLifecycleView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /directory/workspaces/{workspace}/actions/change-commercial-state: + post: + summary: Change the workspace commercial lifecycle state from the system plane + description: | + Conceptual contract for the confirmation-protected state-change action + on the existing system workspace detail page. + + Behavior: + - Platform user with directory visibility but without the dedicated + lifecycle-manage capability: 403 + - Wrong plane or non-platform actor: 404 semantics at the panel boundary + - Authorized platform user: state and rationale are written through the + existing workspace settings audit path + parameters: + - $ref: '#/components/parameters/WorkspaceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangeCommercialLifecycleCommand' + responses: + '204': + description: Commercial lifecycle state changed successfully + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + /onboarding/{onboardingDraft}: + get: + summary: View onboarding workflow with lifecycle-aware completion state + description: | + Renders the existing managed-tenant onboarding wizard. The completion + step must include the commercial lifecycle outcome after the underlying + entitlement substrate has been evaluated. + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + responses: + '200': + description: Onboarding wizard rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/OnboardingCommercialLifecycleView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /onboarding/{onboardingDraft}/actions/complete: + post: + summary: Complete onboarding when entitlement, lifecycle state, and existing readiness all allow + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + responses: + '204': + description: Onboarding completed + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /review-packs/actions/generate: + post: + summary: Generate a review pack from the current tenant context + description: | + Conceptual contract for the tenant dashboard and review-pack list start + action family. + + Behavior ordering: + 1. authorization + 2. underlying entitlement substrate decision + 3. lifecycle overlay decision + 4. existing dedupe / queued-start flow when allowed + + A lifecycle-blocked attempt is future-start-only in this slice: it + creates no new `ReviewPack`, creates no new `OperationRun`, emits no + queued or terminal review-pack notification, and does not affect any + review-pack work that was already queued or running. + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewPackGenerationCommand' + responses: + '202': + description: Generation accepted or deduped through the existing flow + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /tenant-reviews/{tenantReview}/actions/export-executive-pack: + post: + summary: Export an executive pack from an existing tenant review + description: | + Conceptual contract for the review register and tenant review detail + export action family. The lifecycle overlay must block before any new + `ReviewPack` or `OperationRun` is created, emit no queued or terminal + review-pack notification for the blocked attempt, and leave any + already-created queued or running review-pack work unchanged. + parameters: + - $ref: '#/components/parameters/TenantReviewId' + responses: + '202': + description: Export accepted or deduped through the existing flow + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /review-packs/{reviewPack}/actions/regenerate: + post: + summary: Regenerate an existing review pack + description: | + Conceptual contract for the existing review-pack detail regenerate + action. Existing confirmation and dedupe behavior remain in place when + the lifecycle overlay allows the start. A lifecycle-blocked attempt is + future-start-only: it creates no new `ReviewPack`, creates no new + `OperationRun`, emits no queued or terminal review-pack notification, + and leaves any already-created queued or running review-pack work + unchanged. + parameters: + - $ref: '#/components/parameters/ReviewPackId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewPackGenerationCommand' + responses: + '202': + description: Regeneration accepted or deduped through the existing flow + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /tenant-reviews/{tenantReview}: + get: + summary: View existing tenant review while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/TenantReviewId' + responses: + '200': + description: Existing tenant review rendered when current RBAC allows it + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/PreservedReadOnlyView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /review-packs/{reviewPack}: + get: + summary: View existing review pack while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/ReviewPackId' + responses: + '200': + description: Existing review pack rendered when current RBAC allows it + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/PreservedReadOnlyView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /review-packs/{reviewPack}/download: + get: + summary: Download an already-generated review pack while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/ReviewPackId' + responses: + '200': + description: Existing generated pack download is still available when current RBAC allows it + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /evidence-snapshots/{evidenceSnapshot}: + get: + summary: View existing evidence snapshot while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/EvidenceSnapshotId' + responses: + '200': + description: Existing evidence snapshot rendered when current RBAC allows it + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/PreservedReadOnlyView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' +components: + parameters: + WorkspaceId: + name: workspace + in: path + required: true + schema: + type: integer + OnboardingDraftId: + name: onboardingDraft + in: path + required: true + schema: + type: integer + TenantReviewId: + name: tenantReview + in: path + required: true + schema: + type: integer + ReviewPackId: + name: reviewPack + in: path + required: true + schema: + type: integer + EvidenceSnapshotId: + name: evidenceSnapshot + in: path + required: true + schema: + type: integer + responses: + Forbidden: + description: Established-scope actor lacks the required capability + NotFound: + description: Wrong plane, non-member scope, or inaccessible record + BusinessStateBlocked: + description: Actor is otherwise authorized, but the workspace commercial state or underlying entitlement substrate blocks the requested action + content: + application/json: + schema: + $ref: '#/components/schemas/CommercialLifecycleBlockResponse' + ValidationError: + description: Submitted commercial lifecycle state change failed validation + schemas: + ChangeCommercialLifecycleCommand: + type: object + required: + - state + - reason + properties: + state: + $ref: '#/components/schemas/CommercialLifecycleState' + reason: + type: string + description: Required for every explicit lifecycle state change, including an explicit return to active_paid. + minLength: 1 + maxLength: 500 + CommercialLifecycleState: + type: string + enum: + - trial + - grace + - active_paid + - suspended_read_only + ReviewPackGenerationCommand: + type: object + properties: + include_pii: + type: boolean + include_operations: + type: boolean + SystemWorkspaceCommercialLifecycleView: + type: object + required: + - workspace_id + - lifecycle + - affected_behaviors + properties: + workspace_id: + type: integer + lifecycle: + $ref: '#/components/schemas/CommercialLifecycleDecision' + affected_behaviors: + type: array + items: + $ref: '#/components/schemas/CommercialLifecycleActionDecision' + entitlement_substrate: + type: object + description: Existing Spec 247 workspace entitlement summary reused for context + primary_action: + $ref: '#/components/schemas/NextAction' + nullable: true + OnboardingCommercialLifecycleView: + type: object + required: + - onboarding_draft_id + - action_decision + properties: + onboarding_draft_id: + type: integer + action_decision: + $ref: '#/components/schemas/CommercialLifecycleActionDecision' + entitlement_substrate: + type: object + nullable: true + CommercialLifecycleDecision: + type: object + required: + - state + - label + - source + - source_label + properties: + state: + $ref: '#/components/schemas/CommercialLifecycleState' + label: + type: string + source: + type: string + enum: + - default_active_paid + - workspace_setting + source_label: + type: string + description: Rendered source label from the shared lifecycle source mapping used by system detail surfaces. + rationale: + type: string + nullable: true + last_changed_at: + type: string + format: date-time + nullable: true + last_changed_by: + type: string + nullable: true + CommercialLifecycleActionDecision: + type: object + required: + - action_key + - outcome + - lifecycle_state + properties: + action_key: + type: string + enum: + - managed_tenant_activation + - review_pack_start + - review_history_read + - evidence_read + - generated_pack_read + outcome: + type: string + enum: + - allow + - warn + - block + - allow_read_only + reason_family: + type: string + nullable: true + enum: + - commercial_lifecycle + - entitlement_substrate + message: + type: string + nullable: true + lifecycle_state: + $ref: '#/components/schemas/CommercialLifecycleState' + underlying_entitlement_key: + type: string + nullable: true + CommercialLifecycleBlockResponse: + type: object + required: + - reason_family + - message + properties: + reason_family: + type: string + enum: + - commercial_lifecycle + - entitlement_substrate + lifecycle_state: + $ref: '#/components/schemas/CommercialLifecycleState' + nullable: true + message: + type: string + PreservedReadOnlyView: + type: object + required: + - read_only_access_preserved + properties: + read_only_access_preserved: + type: boolean + enum: [true] + lifecycle_state: + $ref: '#/components/schemas/CommercialLifecycleState' + message: + type: string + nullable: true + description: Optional calm explanation that the workspace is suspended read-only while current history access remains available + NextAction: + type: object + required: + - label + properties: + label: + type: string + enabled: + type: boolean + reason: + type: string + nullable: true \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/data-model.md b/specs/251-commercial-entitlements-billing-state/data-model.md new file mode 100644 index 00000000..1629578d --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/data-model.md @@ -0,0 +1,170 @@ +# Data Model: Commercial Entitlements and Billing-State Maturity + +**Date**: 2026-04-28 +**Branch**: `251-commercial-entitlements-billing-state` + +## Overview + +This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while the commercial lifecycle overlay and action-family outcomes remain derived. + +## Persisted Truth + +### 1. Workspace Commercial Lifecycle Setting Aggregate + +**Persistence**: Existing `App\Models\WorkspaceSetting` rows +**Ownership**: Workspace-owned +**Scope**: One workspace, no new tenant-owned or platform-owned persistence + +The slice reuses explicit settings keys under the existing `entitlements` domain. + +| Setting key | Type | Nullable | Validation | Notes | +|-------------|------|----------|------------|-------| +| `entitlements.commercial_lifecycle_state` | string | yes | when present, must be one of `trial`, `grace`, `active_paid`, `suspended_read_only` | `null` means the workspace has never been explicitly set and resolves to the implicit default `active_paid` | +| `entitlements.commercial_lifecycle_reason` | string | yes | required on every explicit lifecycle state change; trimmed; max 500 chars | Operator-entered rationale shown on system and contextual admin surfaces | + +**Write rules**: + +- Lifecycle mutation happens from the system plane only and updates state plus rationale together through the existing workspace settings write/audit path. +- The future `Change commercial state` action is confirmation-protected and requires explicit rationale for every explicit lifecycle transition, including an explicit return to `active_paid`. +- Once a platform operator explicitly sets `active_paid`, that remains a stored state like the other three values. `null` is reserved for untouched workspaces only. + +**Relationships**: + +- `workspace_settings.workspace_id` anchors lifecycle truth to the workspace. +- `workspace_settings.updated_by_user_id` remains the attribution source for state change metadata. + +## Existing Substrate Truth Reused + +### 2. Workspace Entitlement Substrate Summary + +**Persistence**: Existing Spec 247 workspace entitlement settings + code-owned plan-profile catalog +**Owner**: `WorkspaceEntitlementResolver` + +This slice does not remodel the substrate. It reuses: + +- `plan_profile` +- `managed_tenant_activation_limit` +- `review_pack_generation_enabled` +- substrate rationale/source/current-usage metadata + +The lifecycle overlay may warn or restrict after substrate resolution, but it must never expand access beyond what the substrate already allows. + +## Code-Owned Truth + +### 3. Commercial Lifecycle State Catalog Entry + +**Persistence**: none, code-owned +**Ownership**: Product/runtime configuration +**Scope**: current release only + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `id` | string | yes | Stable internal identifier stored in `entitlements.commercial_lifecycle_state` | +| `label` | string | yes | Operator-facing state label | +| `description` | string | yes | Short explanation for system detail and contextual messaging | +| `onboarding_outcome` | string | yes | `allow` or `block` | +| `review_pack_start_outcome` | string | yes | `allow`, `warn`, or `block` | +| `preserves_read_only_history` | bool | yes | Whether existing review/evidence/generated-pack consumption remains explicitly preserved | +| `is_default` | bool | yes | Exactly one default entry: `active_paid` | + +**Behavior matrix**: + +| State | Onboarding activation | Review-pack starts | Existing review/evidence/download access | +|-------|-----------------------|--------------------|------------------------------------------| +| `trial` | allow | allow | allow | +| `active_paid` | allow | allow | allow | +| `grace` | block | warn (start still allowed) | allow | +| `suspended_read_only` | block | block | allow | + +## Derived Truth + +### 4. Effective Commercial Lifecycle Decision + +**Persistence**: none, derived at runtime +**Owner**: bounded `WorkspaceCommercialLifecycleResolver` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `workspace_id` | int | yes | Workspace being evaluated | +| `state` | string | yes | Effective lifecycle state | +| `label` | string | yes | Operator-facing label | +| `source` | string | yes | `default_active_paid` or `workspace_setting`; any rendered source label must come from one shared mapping | +| `rationale` | string | no | Explicit operator rationale when source is `workspace_setting` | +| `last_changed_at` | datetime | no | Derived from the most recent lifecycle-related `WorkspaceSetting` row | +| `last_changed_by` | string | no | Derived actor attribution | +| `entitlement_summary` | object | yes | Existing Spec 247 substrate summary reused for support/context | +| `action_decisions` | object | yes | Per-action-family outcomes described below | + +### 5. Commercial Lifecycle Action Decision + +**Persistence**: none, derived at runtime + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `action_key` | string | yes | One of `managed_tenant_activation`, `review_pack_start`, `review_history_read`, `evidence_read`, `generated_pack_read` | +| `outcome` | string | yes | `allow`, `warn`, `block`, or `allow_read_only` | +| `reason_family` | string | no | `commercial_lifecycle`, `entitlement_substrate`, or `null` when fully allowed | +| `message` | string | no | Operator-safe explanation or warning | +| `lifecycle_state` | string | yes | Effective state that produced the action decision | +| `underlying_entitlement_key` | string | no | Present for onboarding/review-pack start decisions to preserve substrate traceability | + +**Decision ordering rules**: + +- The substrate entitlement decision runs first. +- If the substrate already blocks the action, the lifecycle overlay must not replace that reason. +- If the substrate allows the action, the lifecycle overlay may warn or block according to the state matrix. +- Authorization is not part of this derived decision; 404 and 403 semantics remain outside and happen earlier. + +## Supporting Derived View Models + +### 6. System Workspace Commercial Lifecycle View Model + +**Persistence**: none +**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace` + +Contains: + +- effective lifecycle state, label, rationale, and last-change attribution +- the two in-scope action-family outcomes +- the reused entitlement substrate summary for support context +- the one dominant mutation affordance metadata for `Change commercial state` + +### 7. Contextual Admin Lifecycle Gate View Models + +**Persistence**: none +**Consumers**: `ManagedTenantOnboardingWizard`, review-pack entry surfaces, and suspended read-only history surfaces + +Contains: + +- the immediate action-family outcome (`allow`, `warn`, `block`, or `allow_read_only`) +- one operator-safe explanation +- enough substrate context to keep lifecycle blocks distinct from underlying entitlement blocks + +## Derived Query Dependencies + +| Need | Source | Notes | +|------|--------|-------| +| Underlying plan-profile and entitlement truth | `WorkspaceEntitlementResolver` | Remains the canonical substrate | +| Lifecycle last-change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from lifecycle-related rows only | +| Active managed-tenant usage | existing tenant/workspace runtime truth | Reused from the substrate summary | +| Existing review/history/evidence/download availability | existing review pack, review, evidence snapshot, and RBAC truth | No new persistence needed | +| Review-pack no-run proof | existing `review_packs` and `operation_runs` tables | Used only in tests to prove blocked starts do not write new run state | + +## State Transitions + +There is no new table-backed lifecycle entity. State changes are explicit workspace-setting transitions plus audit entries. + +| From | To | Trigger | Consequence | +|------|----|---------|-------------| +| `null` (implicit default) | any explicit state | platform operator saves lifecycle state on the system detail page | workspace now has explicit commercial posture, rationale, and attribution | +| `trial` | `grace` | platform operator state change | new managed-tenant activation blocks; review-pack starts remain allowed with warning | +| `grace` | `suspended_read_only` | platform operator state change | onboarding and new review-pack starts block; history/evidence/download remain available | +| `suspended_read_only` | `active_paid` | platform operator state change | future starts again defer to underlying entitlement truth | +| any explicit state | another explicit state | platform operator state change | previous state is replaced; audit history preserves the transition trail | + +## Boundaries Explicitly Preserved + +- No new billing/customer/subscription entity exists. +- No new automated timers, expiry jobs, renewal reminders, or scheduled transitions are introduced. +- No new broad suspension contract is added for unrelated mutable surfaces. +- Existing read-only review/evidence/generated-pack access remains governed by current RBAC and redaction rules. \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/plan.md b/specs/251-commercial-entitlements-billing-state/plan.md new file mode 100644 index 00000000..2517b115 --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/plan.md @@ -0,0 +1,297 @@ +# Implementation Plan: Commercial Entitlements and Billing-State Maturity + +**Branch**: `251-commercial-entitlements-billing-state` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Layer one bounded workspace commercial lifecycle overlay on top of the already-real Spec 247 entitlement substrate, not beside it. The existing `WorkspaceEntitlementResolver` remains canonical for plan/default/override truth, and the new slice adds one explicit lifecycle state plus action-family outcomes for onboarding activation, review-pack start, and preserved read-only history access. +- Keep mutation narrow and platform-owned: persist lifecycle state through the existing workspace settings infrastructure, expose inspection plus state change from the existing system workspace detail surface, and keep `/admin` limited to contextual allow, warn, or block messaging on onboarding and review-pack surfaces. +- Preserve current review/evidence/download truth while suspended. New lifecycle blocking must stop future onboarding activation and future review-pack starts before any tenant mutation, `ReviewPack`, or `OperationRun` creation, while leaving already-generated history and evidence consumption under current RBAC intact. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page +**Storage**: PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model +**Testing**: Pest unit and feature tests via Laravel Sail +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Monorepo Laravel web application in `apps/platform`, using existing Filament admin and system panels +**Project Type**: web +**Performance Goals**: Reuse existing settings reads and current workspace aggregates only, add no new external calls during render, keep review-pack dedupe and shared run UX unchanged when allowed, and short-circuit blocked review-pack starts before any `ReviewPack` or `OperationRun` write +**Constraints**: One commercial lifecycle overlay only, four bounded states, two real gated behavior families, preserved authorized read-only history/evidence/download access while suspended, explicit `/admin` vs `/system` separation, no payment provider/invoice/checkout/website/broad billing-engine scope +**Scale/Scope**: One bounded lifecycle resolver, one system-plane mutation surface, one platform capability addition, one onboarding gate, one review-pack action-family gate, and focused lifecycle/read-only test coverage + +## Filament v5 / Panel Notes + +- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, and Livewire-backed actions. No Livewire v3 assumptions or compatibility work are introduced. +- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`. +- **Global search**: No new globally searchable resource is introduced. Current global-search behavior remains unchanged. +- **Destructive and high-impact actions**: The future `Change commercial state` action on the system workspace detail page must use `->requiresConfirmation()`, require platform authorization, and write audit history. The `Suspended / read-only` transition is the only high-risk path in scope. Review-pack and onboarding blocks remain non-destructive business-state responses, not hidden authorization failures. +- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed elsewhere in the product. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: mixed +- **Shared-family relevance**: system detail controls, status messaging, onboarding helper text, review-pack action gating, review/evidence viewer messaging +- **State layers in scope**: page, detail +- **Audience modes in scope**: operator-MSP, support-platform, customer/read-only +- **Decision/diagnostic/raw hierarchy plan**: decision-first on the system workspace detail surface and on the immediate onboarding/review-pack action context; diagnostics-second via the existing entitlement substrate and review/run/history context; no new raw/support payload surface is planned +- **Raw/support gating plan**: capability-gated system-plane inspection only; customer/read-only surfaces remain calm and evidence-first +- **One-primary-action / duplicate-truth control**: `/system/directory/workspaces/{workspace}` remains the only mutation surface; onboarding and review-pack surfaces show only the local lifecycle consequence required for the immediate action; suspended read-only history pages do not restate the whole commercial profile +- **Handling modes by drift class or surface**: review-mandatory because one lifecycle vocabulary must stay consistent across system, onboarding, review-pack, and read-only history surfaces +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: no second admin-plane commercial mutation surface, no page-local lifecycle labels, and no broad suspension sweep across unrelated mutable surfaces +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, existing workspace-setting audit path, `WorkspaceEntitlementResolver`, `WorkspaceSettings` as the current entitlement substrate reference, `App\Filament\System\Pages\Directory\ViewWorkspace`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, `TenantReviewPackCard`, `ReviewRegister`, `TenantReviewResource`, `ReviewPackResource`, `CustomerReviewWorkspace`, `EvidenceSnapshotResource`, and `WorkspaceEntitlementBlockedException` +- **Shared abstractions reused**: `WorkspaceEntitlementResolver`, `WorkspacePlanProfileCatalog`, `SettingsResolver`, `SettingsWriter`, current workspace audit logging, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and existing Filament action/resource surfaces +- **New abstraction introduced? why?**: one bounded `WorkspaceCommercialLifecycleResolver` is justified because the existing entitlement resolver answers per-key entitlement truth but does not express one workspace-wide lifecycle posture with action-family outcomes, preserved read-only semantics, or system/admin messaging +- **Why the existing abstraction was sufficient or insufficient**: Spec 247 already provides canonical entitlement substrate truth and must remain the foundation. It is insufficient for `trial`, `grace`, `active_paid`, and `suspended_read_only` because those states cut across more than one entitlement key and need one central business-state explanation +- **Bounded deviation / spread control**: no page-local lifecycle conditionals and no second exception taxonomy by default; prefer reusing the existing blocked decision payload/catch path for review-pack actions unless implementation proves that the current class name or payload cannot carry lifecycle metadata cleanly + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: existing shared review-pack OperationRun start UX through `ReviewPackService`, `OperationUxPresenter`, and `OperationRunLinks` +- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe messaging, and existing terminal notifications remain unchanged when review-pack generation is allowed; lifecycle-blocked starts create no `OperationRun`, no queued DB notification, and no terminal notification +- **Surface-owned behavior kept local**: onboarding completion helper text, review-pack tooltips/disabled state, and suspended read-only explanation on history surfaces remain local projections of the central lifecycle decision +- **Queued DB-notification policy**: unchanged explicit opt-in only +- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only +- **Exception path**: none planned; lifecycle blocking must happen before `ReviewPackService` creates or reuses a `ReviewPack` or `OperationRun`, and the preferred later implementation is to extend the current blocked-decision payload rather than invent a second parallel business-state exception family + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: workspace commercial lifecycle vocabulary, lifecycle rationale, action-family outcomes, system/admin messaging, and audit semantics +- **Neutral platform terms / contracts preserved**: `workspace`, `trial`, `grace`, `active paid`, `suspended / read-only`, `commercial state`, `review pack`, `managed tenant activation` +- **Retained provider-specific semantics and why**: none; review-pack generation stays provider-backed operationally, but the new lifecycle vocabulary remains platform-core and provider-neutral +- **Bounded extraction or follow-up path**: none + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS - the slice adds workspace-owned business state, not new inventory or backup truth. +- Read/write separation: PASS - the only new write is a confirmation-protected, audited system-plane lifecycle mutation using existing workspace settings persistence. +- Graph contract path: PASS - no new Microsoft Graph path is introduced. +- Deterministic capabilities: PASS - admin-plane capabilities remain unchanged, and any new platform capability stays registry-backed. +- RBAC-UX: PASS - `/admin` and `/system` remain separated; wrong-plane and non-member access stay 404; member-without-capability stays 403; otherwise-authorized actors get a business-state block or warning instead of authorization failure. +- Workspace isolation: PASS - admin-plane contextual behavior still requires established workspace context. +- RBAC-UX destructive confirmation: PASS - the future system-plane state-change action must require confirmation and rationale. +- RBAC-UX global search: PASS - no new searchable resource or search scope is introduced. +- Tenant isolation: PASS - onboarding, review-pack, review history, evidence, and download surfaces remain tenant-safe. +- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked starts stop before run creation. +- OperationRun start UX: PASS - the plan preserves shared review-pack start UX and inserts lifecycle blocking before run creation. +- Ops-UX 3-surface feedback: PASS - existing feedback stays toast + progress surfaces + terminal notification only when a run exists. +- Ops-UX lifecycle: PASS - no new `OperationRun` lifecycle contract is introduced. +- Ops-UX summary counts: N/A - no `summary_counts` shape change is planned. +- Ops-UX guards: N/A - no new run guard family is planned in the planning slice. +- Ops-UX system runs: N/A - initiator-null behavior is unchanged. +- Automation: N/A - no new queued or scheduled workflow family is introduced. +- Data minimization: PASS - no payment payloads, account records, or provider secrets are introduced. +- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth. +- Proportionality (PROP-001): PASS - persistence stays in existing settings rows, and the only new structural element is one bounded lifecycle overlay. +- No premature abstraction (ABSTR-001): PASS - no interface, registry, strategy system, or framework is planned; only one local resolver is added because multiple real surfaces already need the same lifecycle decision. +- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced. +- Behavioral state (STATE-001): PASS - `grace` and `suspended_read_only` create distinct action-family consequences immediately, and `trial` remains justified because it is part of the explicit platform-managed commercial posture and audit workflow even though its two in-scope gated families currently match `active_paid`. +- UI semantics (UI-SEM-001): PASS - the plan prefers direct mapping from lifecycle truth to helper text and badges instead of a new presentation framework. +- Shared pattern first (XCUT-001): PASS - system detail, onboarding, review-pack, and read-only history surfaces all reuse the existing substrate and shared run path first. +- Provider boundary (PROV-001): PASS - the new vocabulary is platform-core, not Microsoft-shaped. +- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one explicit state family plus one thin overlay resolver is the narrowest viable shape. +- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the plan keeps the whole lifecycle overlay in one coherent spec and includes proportionality review below. +- Badge semantics (BADGE-001): PASS - any future lifecycle badge must reuse shared badge semantics or stay plain text; no page-local color taxonomy is planned. +- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, resources, widgets, and the current system Blade page. +- Filament-native UI local Blade/Tailwind: PASS - the existing system Blade view remains the only custom-rendered surface in scope and must preserve current Filament visual language. +- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing system detail, guided onboarding, action family, and read-only viewer surface types remain intact. +- Decision-first operating model (DECIDE-001): PASS - system workspace detail is primary, onboarding/review-pack surfaces stay contextual, and read-only history/evidence pages remain tertiary evidence/diagnostics. +- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - system detail stays platform/support-facing, admin action gates stay operator-first, and suspended read-only pages keep customer-safe history access without raw platform diagnostics. +- UI/UX inspect model (UI-HARD-001): PASS - no duplicate inspect affordances are added. +- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - the plan keeps one system mutation action and existing onboarding/review-pack primary actions in place. +- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - labels remain narrow and billing-provider-free. +- UI/UX placeholder ban (UI-HARD-001): PASS - no empty action groups are planned. +- UI naming (UI-NAMING-001): PASS - primary labels stay `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. +- Operator surfaces (OPSURF-001): PASS - mutation scope remains explicit, and `/admin` surfaces only show contextual lifecycle truth. +- Operator surface page contract: PASS - the spec already defines the required surface contracts. +- Filament UI Action Surface Contract: PASS - touched surfaces already have contracts or exemptions; the plan preserves them while adding lifecycle truth. +- Filament UI UX-001 (Layout & IA): PASS - no new page shell or panel is introduced. +- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - one system primary action and existing onboarding/review-pack action families remain the only primary mutations in scope. +- UI review workflow: PASS - guardrail, shared-family, and exception posture remain explicit in this plan. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for the bounded lifecycle overlay and behavior matrix; `Feature` for system-plane mutation, onboarding activation gating, review-pack start blocking, and preserved suspended read-only consumption +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: the business risk is deterministic decision ordering plus existing Filament/Livewire and service entry points. Browser or heavy-governance coverage would add cost without proving additional current-release risk for this bounded overlay. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` +- **Fixture / helper / factory / seed / context cost risks**: limited to workspace, platform user, workspace member, onboarding draft, tenant, existing review pack, tenant review, and evidence snapshot fixtures +- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and opt-in tenant/review/evidence helpers only +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native relief for system detail and onboarding; monitoring-state-page proof for no new run creation; shared-detail-family proof for preserved view/download access while suspended +- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify 404 vs 403 vs business-state outcomes separately, verify system detail source labels remain consistent, verify blocked review-pack starts create no new `ReviewPack` or `OperationRun` and emit no queued or terminal notification, verify already queued or running review-pack runs continue unaffected after later suspension, and verify suspended workspaces still allow authorized review/evidence/download access +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth +- **Review-stop questions**: does the state vocabulary stay narrow enough, does the system/admin split remain intact, does suspended read-only coverage avoid broad mutation-sweep scope, and does the blocked-decision transport avoid a second exception framework +- **Escalation path**: document-in-feature if only payload wording or helper reuse needs adjustment; follow-up-spec only if the overlay starts pulling unrelated mutable surfaces into suspension logic +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one overlay resolver and a small set of existing pages/services/resources; no new browser or heavy-governance harness is justified + +## Project Structure + +### Documentation (this feature) + +```text +specs/251-commercial-entitlements-billing-state/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── workspace-commercial-lifecycle-overlay.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── Reviews/CustomerReviewWorkspace.php +│ │ │ ├── Reviews/ReviewRegister.php +│ │ │ ├── Settings/WorkspaceSettings.php +│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php +│ │ ├── Resources/ +│ │ │ ├── EvidenceSnapshotResource.php +│ │ │ ├── EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php +│ │ │ ├── ReviewPackResource.php +│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php +│ │ │ ├── TenantReviewResource.php +│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php +│ │ ├── System/Pages/Directory/ViewWorkspace.php +│ │ └── Widgets/Tenant/TenantReviewPackCard.php +│ ├── Models/WorkspaceSetting.php +│ ├── Services/ +│ │ ├── Entitlements/WorkspaceCommercialLifecycleResolver.php # likely new bounded overlay service +│ │ ├── Entitlements/WorkspaceEntitlementResolver.php +│ │ ├── ReviewPackService.php +│ │ └── Settings/ +│ │ ├── SettingsResolver.php +│ │ └── SettingsWriter.php +│ ├── Support/ +│ │ ├── Auth/Capabilities.php +│ │ ├── Auth/PlatformCapabilities.php +│ │ └── Settings/SettingsRegistry.php +├── resources/views/filament/system/pages/directory/view-workspace.blade.php +└── tests/ + ├── Feature/ + └── Unit/ +``` + +**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded lifecycle overlay service and changes limited to existing settings persistence, system detail, onboarding, review-pack, and read-only review/evidence/download surfaces plus focused Pest coverage. + +## Likely Implementation Surfaces + +- `app/Support/Settings/SettingsRegistry.php` to register lifecycle-setting definitions and validation using the existing workspace settings infrastructure +- `app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` as the new bounded overlay, with `WorkspaceEntitlementResolver.php` remaining the canonical substrate provider +- `app/Support/Auth/PlatformCapabilities.php` and related platform authorization helpers for one dedicated commercial-lifecycle management capability +- `app/Filament/System/Pages/Directory/ViewWorkspace.php` and `resources/views/filament/system/pages/directory/view-workspace.blade.php` for read-only summary plus the confirmation-protected state-change action +- `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` for contextual lifecycle messaging and activation blocking before tenant mutation +- `app/Services/ReviewPackService.php`, `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` for shared start gating and tooltip/disabled-state reuse +- Existing read-only consumption surfaces `CustomerReviewWorkspace.php`, `ViewTenantReview.php`, `ViewReviewPack.php`, `ViewEvidenceSnapshot.php`, and the current review-pack download path to prove suspended history/evidence access remains available under existing RBAC +- Focused unit and feature tests under `tests/Unit/Entitlements`, `tests/Feature/System/Directory`, `tests/Feature/Onboarding`, `tests/Feature/ReviewPack`, `tests/Feature/Reviews`, and `tests/Feature/Evidence` + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One bounded `WorkspaceCommercialLifecycleResolver` | Two real gated behavior families plus preserved read-only consumption need one shared workspace-wide decision layered above existing entitlements | Page-local conditionals in onboarding, review-pack resources/widgets, and system detail would drift immediately and undermine business-state consistency | +| Four-state commercial lifecycle vocabulary | Platform operators need one auditable commercial posture that distinguishes trial, grace, active paid, and suspended/read-only on the single system decision surface | Three unlabeled booleans or ad hoc flags would either collapse grace into suspension or lose the explicit platform-side lifecycle state needed for support and audit | + +## Proportionality Review + +- **Current operator problem**: The repo can already answer per-key entitlement questions, but it cannot say in one place whether a workspace is currently trialing, in grace, fully active paid, or suspended/read-only, nor can it explain why onboarding and review-pack starts are blocked while history remains readable. +- **Existing structure is insufficient because**: `WorkspaceEntitlementResolver` and current workspace settings expose substrate truth only. They do not provide one workspace-wide lifecycle posture, one system-owned mutation path, or one action-family outcome that distinguishes lifecycle blocks from entitlement blocks and authorization failures. +- **Narrowest correct implementation**: Keep persistence inside existing `workspace_settings`, add only one four-state lifecycle family plus rationale, derive action-family outcomes in one bounded overlay service, mutate it from one system detail page, and apply it only to onboarding activation, review-pack starts, and preserved read-only history/evidence/download semantics. +- **Ownership cost created**: One new state vocabulary, one overlay service, one platform capability, cross-surface copy discipline, and focused lifecycle/read-only tests. +- **Alternative intentionally rejected**: A billing/subscription engine, customer-account model, payment-provider seam, or many local page booleans was rejected because the current release only needs a single workspace commercial overlay on top of the already-real entitlement substrate. +- **Release truth**: current-release truth. The four-state vocabulary is justified now because platform operators already need to set and audit those named postures, even though only `grace` and `suspended_read_only` introduce new blocked outcomes for the two in-scope action families in this slice. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md` + +Goals: +- Confirm the narrowest reuse of the existing `entitlements` settings domain and audit path for lifecycle state and rationale. +- Confirm that one bounded overlay service can compose `WorkspaceEntitlementResolver` without creating a second commercial framework. +- Confirm that lifecycle mutation remains platform-only on the existing system workspace detail page and does not leak into `/admin` self-service. +- Confirm that review-pack start blocking happens before `ReviewPack` or `OperationRun` creation and can reuse the current blocked-decision transport. +- Confirm that suspended read-only preservation remains bounded to existing review, evidence, and generated-pack consumption surfaces instead of becoming a broad product-wide suspension sweep. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` + +Design focus: +- Persist `commercial_lifecycle_state` plus rationale through the existing `entitlements` settings domain instead of adding a new table or billing domain. +- Keep the overlay inside `App\Services\Entitlements` and let it compose `WorkspaceEntitlementResolver` rather than replacing it. +- Extend the existing system workspace detail page with a read-only lifecycle summary and one confirmation-protected `Change commercial state` action, while leaving `WorkspaceSettings` as substrate truth rather than a second mutation plane. +- Gate `ManagedTenantOnboardingWizard` completion from the central lifecycle decision after underlying entitlement truth is known. +- Gate review-pack `Generate pack`, `Regenerate`, and `Export executive pack` starts through `ReviewPackService` and current action surfaces, stopping before any `ReviewPack` or `OperationRun` write when the lifecycle blocks the action. +- Preserve `CustomerReviewWorkspace`, review detail, evidence detail, review-pack detail, and pack download access under current RBAC while suspended, and keep any broader mutable-surface suspension work explicitly out of scope. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the completed plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created later by `/speckit.tasks`) + +- Register lifecycle state and rationale setting definitions under the existing `entitlements` settings domain and wire them into the current workspace-setting audit path. +- Add one bounded `WorkspaceCommercialLifecycleResolver` that composes underlying entitlement decisions and yields action-family outcomes plus suspended read-only allowances. +- Add one dedicated platform capability for commercial lifecycle management and enforce it on the system detail mutation action only. +- Extend `ViewWorkspace` plus its Blade view with current lifecycle state, affected behavior summary, and a confirmation-protected `Change commercial state` action. +- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared lifecycle decision while preserving existing tenant operability checks and 404/403 semantics. +- Gate review-pack start surfaces and `ReviewPackService` using the shared lifecycle decision, preserving current queued-start UX when allowed and reusing the existing blocked-decision transport when blocked. +- Prove suspended read-only continuation by asserting existing review/evidence/download surfaces remain available under current RBAC while no new onboarding activation or review-pack run can start. +- Add focused Sail/Pest unit and feature coverage only. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The design keeps Filament v5 + Livewire v4 compliance intact, leaves provider registration unchanged in `bootstrap/providers.php`, introduces no new globally searchable resource, keeps asset strategy unchanged, preserves strict `/admin` vs `/system` separation, layers one bounded lifecycle resolver above the existing entitlement substrate, and blocks review-pack starts before `OperationRun` creation rather than forking shared run UX. + +## Planning Readiness + +- Outcome: keep +- No unresolved clarification markers remain in the plan-phase artifacts. +- No application implementation is included in this planning step. +- The next repo-native step is `/speckit.tasks` for an implementation task breakdown, not code changes. + +## Implementation Close-Out + +- **Workflow outcome**: keep. +- **Implementation result**: one bounded commercial lifecycle overlay was implemented through existing workspace settings, one system-plane `Change commercial state` action, onboarding activation gating, review-pack start allow/warn/block semantics, and preserved suspended read-only review/evidence/download access. +- **Blocked-decision transport**: document-in-feature. The existing `WorkspaceEntitlementBlockedException` transport remains sufficient for review-pack blocked starts; no second business-state exception family was introduced. +- **Preserved read-only scope**: document-in-feature. Suspension stays bounded to onboarding activation and new review-pack starts in this spec; broader mutable-surface suspension remains out of scope. +- **Browser smoke path**: `/system/login` as `operator@tenantpilot.io`, `/system/directory/workspaces/1`, open `Change commercial state`, set `Trial` with rationale, confirm, observe updated lifecycle summary and notification follow-up, then restore `Active paid`. +- **Browser smoke result**: pass after fixing `WorkspaceResolver` to accept Livewire serialized workspace route parameters; the follow-up notification update no longer emits console errors or 404/419 markers. +- **Lane results**: targeted unit/support/system/onboarding/review-pack/read-only Pest lanes passed; dirty-only Pint passed; `git diff --check` passed. diff --git a/specs/251-commercial-entitlements-billing-state/quickstart.md b/specs/251-commercial-entitlements-billing-state/quickstart.md new file mode 100644 index 00000000..543fe227 --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/quickstart.md @@ -0,0 +1,109 @@ +# Quickstart: Commercial Entitlements and Billing-State Maturity + +**Date**: 2026-04-28 +**Branch**: `251-commercial-entitlements-billing-state` + +This quickstart is the intended reviewer flow after implementation. It stays bounded to the commercial lifecycle overlay described in the spec. + +## Prerequisites + +1. Start the local platform stack. + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d` +2. Ensure one platform user has directory visibility plus the dedicated commercial lifecycle management capability. +3. Ensure one workspace member can complete onboarding, one reporting operator can manage review packs, and one customer-safe or operator read-only actor can open review/evidence/download surfaces under current RBAC. +4. Seed or factory-create: + - one workspace with untouched lifecycle state + - one onboarding draft in that workspace + - one tenant with an existing review, evidence snapshot, and generated review pack + - one workspace already at or above the managed-tenant activation limit for substrate-block verification + +## Scenario 1: Change workspace commercial state from the system plane + +1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user. +2. Confirm the page shows: + - current lifecycle state + - source label + - rationale and last-changed attribution + - affected behavior summary for onboarding and review-pack starts + - the underlying entitlement substrate summary for context +3. Use `Change commercial state` to move the workspace to `trial` with rationale. +4. Confirm the page updates immediately and the change is attributable. +5. Repeat with `grace`, `suspended_read_only`, and `active_paid`. +6. Confirm every explicit state change requires rationale, including a return to `active_paid`, and that the `Suspended / read-only` path also requires explicit confirmation. + +## Scenario 2: Gate onboarding activation with business-state truth + +1. Open `/admin/onboarding/{onboardingDraft}` for a workspace in `trial` or `active_paid`. +2. Confirm the completion step allows `Complete onboarding` when the underlying entitlement substrate also allows it. +3. Switch the same workspace to `grace` from the system plane. +4. Refresh the onboarding draft and confirm: + - the action remains visible for an otherwise authorized actor + - the step explains that expansion is frozen during grace + - no tenant activation occurs +5. Repeat with `suspended_read_only` and confirm the block message changes to read-only suspension semantics instead of a permission failure. + +## Scenario 3: Gate review-pack starts before any run is created + +1. Use a workspace in `trial` or `active_paid` where the underlying review-pack entitlement allows generation. +2. Trigger the current start family from: + - tenant dashboard review-pack card + - review register export action + - tenant review detail export action + - review-pack detail regenerate action +3. Confirm the existing queued-start UX remains unchanged when allowed. +4. Move the workspace to `grace`. +5. Confirm review-pack starts remain allowed with a grace warning. +6. Start one allowed review-pack action and leave the resulting work queued or running. +7. Move the workspace to `suspended_read_only`. +8. Confirm the already-created run remains visible and continues with the existing run UX. +9. Repeat the same start actions and confirm: + - each surface shows the same lifecycle-based reason + - no new `ReviewPack` row is created + - no new `OperationRun` row is created + - no queued or terminal review-pack notification is emitted for the blocked attempt + +## Scenario 4: Preserve read-only review, evidence, and generated-pack access while suspended + +1. Keep the workspace in `suspended_read_only`. +2. Open the current read-only consumption surfaces as an already-authorized actor: + - `CustomerReviewWorkspace` + - tenant review detail + - review-pack detail + - evidence snapshot detail + - current review-pack download link +3. Confirm: + - the pages still render + - already-generated review packs remain downloadable + - existing review/evidence history remains visible + - any read-only explanation stays calm and does not masquerade as 403 or 404 +4. Confirm the slice does not add broad new suspension behavior to unrelated mutable controls outside the spec boundary. + +## RBAC and Plane Semantics Checks + +1. Access lifecycle mutation from `/admin` and confirm there is no self-service control surface. +2. Access `/system/directory/workspaces/{workspace}` as a platform user lacking the dedicated lifecycle capability and confirm authorization is enforced without leaking admin-plane truth. +3. Access onboarding or review-pack surfaces as a non-member or wrong-plane actor and confirm 404. +4. Access the same surfaces as an established-scope actor lacking the relevant capability and confirm 403. +5. Access the action as an otherwise authorized actor whose workspace lifecycle blocks the action and confirm a truthful business-state block instead of 403 or 404. + +## Targeted Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Out of Scope Confirmations + +While validating this slice, confirm that the implementation does not add or imply: + +- payment-provider credentials, invoices, checkout, taxes, or public pricing UI +- customer-account, subscription, or contract models +- automated expiry/reminder/renewal logic +- a second admin-plane commercial settings surface +- a broad suspension engine across unrelated mutable product surfaces \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/research.md b/specs/251-commercial-entitlements-billing-state/research.md new file mode 100644 index 00000000..eacd751e --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/research.md @@ -0,0 +1,84 @@ +# Research: Commercial Entitlements and Billing-State Maturity + +**Date**: 2026-04-28 +**Branch**: `251-commercial-entitlements-billing-state` + +## Decision 1: Persist lifecycle truth inside the existing `entitlements` settings domain + +- **Decision**: Store the workspace commercial lifecycle overlay through explicit `WorkspaceSetting` keys in the existing `entitlements` domain, conceptually `commercial_lifecycle_state` plus `commercial_lifecycle_reason`. +- **Rationale**: Spec 247 already proved that workspace-owned commercial truth belongs in the existing workspace settings infrastructure. Reusing that path keeps audit behavior, validation, and source-of-truth ownership consistent without inventing a billing/account model or a second persistence family. +- **Alternatives considered**: + - New `subscriptions`, `billing_states`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope. + - A separate `commercial` settings domain: rejected because the new state is an overlay on the already-real entitlement substrate, not a second independent settings family. + +## Decision 2: Add one bounded lifecycle overlay service above `WorkspaceEntitlementResolver` + +- **Decision**: Introduce one bounded `WorkspaceCommercialLifecycleResolver` in `App\Services\Entitlements` that composes `WorkspaceEntitlementResolver` instead of replacing it. +- **Rationale**: The underlying entitlement resolver remains canonical for plan-profile defaults, override values, and per-key allow/block truth. The new feature needs one additional workspace-wide layer that can answer lifecycle state, lifecycle rationale, and action-family outcomes across onboarding, review-pack starts, and preserved read-only history access. +- **Alternatives considered**: + - Extend `WorkspaceEntitlementResolver` until it also owns lifecycle posture: rejected because that would blur substrate truth with the new overlay and make future review of state ordering harder. + - Local page/service conditionals in onboarding, review-pack resources, and system detail: rejected because they would drift immediately. + +## Decision 3: Keep system-plane mutation on the existing workspace detail page only + +- **Decision**: Make `/system/directory/workspaces/{workspace}` the only mutation surface for lifecycle state changes, with inspection plus a confirmation-protected `Change commercial state` action. +- **Rationale**: The spec requires platform-managed lifecycle mutation. The existing system workspace detail page already exposes commercial truth read-only and is the narrowest platform context that can show state, rationale, and audit attribution without creating a second control plane. +- **Alternatives considered**: + - Add lifecycle mutation to `/admin/settings/workspace`: rejected because the slice must not become a self-service workspace-admin commercial control surface. + - Create a dedicated system commercial page/resource: rejected because the existing workspace detail page already anchors the platform/support workflow. + +## Decision 4: Preserve explicit business-state versus authorization semantics + +- **Decision**: Keep non-member and wrong-plane access as 404, keep established-scope capability denial as 403, and treat lifecycle blocking or warnings as business-state results for otherwise authorized actors. +- **Rationale**: This is the main operator value of the slice. The commercial lifecycle overlay must explain why an action is blocked without pretending the actor lacks scope or permission. +- **Alternatives considered**: + - Hide blocked actions entirely: rejected because it would erase the commercial explanation the feature exists to provide. + - Return 403 for lifecycle blocks: rejected because it would conflate business state with authorization. + +## Decision 5: Review-pack lifecycle blocking must happen before `ReviewPack` or `OperationRun` creation + +- **Decision**: Reuse `ReviewPackService` as the hard enforcement boundary and block lifecycle-restricted starts before any `ReviewPack` or `OperationRun` write occurs. +- **Rationale**: Current review-pack start surfaces already converge on `ReviewPackService`. Blocking at the service boundary prevents UI-surface bypass and preserves the shared OperationRun start UX for allowed actions. +- **Alternatives considered**: + - UI-only disabling on each widget/resource/page action: rejected because it would not protect direct action execution. + - A new review-pack lifecycle queue/framework: rejected because the slice changes eligibility only, not run orchestration. + +## Decision 6: Reuse the existing blocked-decision transport if it can carry lifecycle metadata cleanly + +- **Decision**: Prefer reusing `WorkspaceEntitlementBlockedException` and extending its decision payload for lifecycle blocks, rather than introducing a second parallel business-state exception family. +- **Rationale**: Review-pack widgets/resources already catch `WorkspaceEntitlementBlockedException` and project its `block_reason` into user-visible feedback. Reusing that transport keeps the change narrow unless implementation proves the class name or payload shape is too substrate-specific. +- **Alternatives considered**: + - New `WorkspaceCommercialLifecycleBlockedException`: rejected for now because it would widen changes across all review-pack action surfaces without proving extra value. + - Plain string returns without a shared decision payload: rejected because the UI surfaces already consume structured block context. + +## Decision 7: Preserve suspended read-only access by leaving existing history/evidence/download routes outside the new gate + +- **Decision**: Keep `CustomerReviewWorkspace`, `ViewTenantReview`, `ViewReviewPack`, `ViewEvidenceSnapshot`, and current review-pack download access outside the new lifecycle start gate, while allowing them to show a calm read-only explanation when helpful. +- **Rationale**: The feature promise is not "suspend everything." It is "block future starts while preserving safe existing history." Existing view/download routes already encode current RBAC and redaction semantics and are the narrowest place to preserve that truth. +- **Alternatives considered**: + - Broad product-wide suspension of all mutable controls: rejected because the spec explicitly forbids a broad suspension engine. + - No plan for preserved read access: rejected because suspension would otherwise appear as total lockout and break the evidence/history requirement. + +## Decision 8: Keep the four-state vocabulary, but justify it narrowly + +- **Decision**: Keep exactly four lifecycle states: `trial`, `grace`, `active_paid`, and `suspended_read_only`. +- **Rationale**: The spec requires these named postures, and platform operators need to set and audit them explicitly from one system surface. `grace` and `suspended_read_only` have immediate distinct action-family consequences. `trial` remains in scope because the platform/support workflow and audit trail need to distinguish temporary non-paid posture from steady active paid posture now, even though both allow the two in-scope gated behavior families. +- **Alternatives considered**: + - Collapse to three states by removing `trial`: rejected because it would erase a required current-release commercial posture and force later renaming/migration when trial lifecycle work grows. + - Persist only booleans like `is_suspended` and `is_in_grace`: rejected because that would not yield one clear operator-facing commercial state. + +## Decision 9: Prove the slice with focused unit and feature lanes only + +- **Decision**: Use one unit family for lifecycle resolution and focused feature tests for system mutation, onboarding gating, review-pack no-run blocking, and suspended read-only consumption. +- **Rationale**: The primary risk is correctness of decision ordering and bounded surface behavior, not browser layout or heavy orchestration. +- **Alternatives considered**: + - Browser tests: rejected because no browser-only interaction risk is introduced in the planning slice. + - Heavy-governance suite expansion: rejected because the scope is feature-local and uses existing surfaces. + +## Decision 10: Leave panels, assets, and global search unchanged + +- **Decision**: Do not add new panels, provider registration changes, global-search resources, or Filament assets as part of this slice. +- **Rationale**: The feature is a business-state overlay inside existing admin and system surfaces. Infrastructure changes would widen scope without helping the current release. +- **Alternatives considered**: + - New commercial panel: rejected because `/system` detail already anchors the platform workflow. + - Asset-backed custom commercial UI: rejected because current Filament components and the existing Blade detail view are sufficient. \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/spec.md b/specs/251-commercial-entitlements-billing-state/spec.md new file mode 100644 index 00000000..0f16fc90 --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/spec.md @@ -0,0 +1,332 @@ +# Feature Specification: Commercial Entitlements and Billing-State Maturity + +**Feature Branch**: `251-commercial-entitlements-billing-state` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Commercial lifecycle follow-up on top of the already-real Spec 247 entitlement substrate, with one central workspace lifecycle resolution, bounded lifecycle states, two real gated behaviors, explicit read-only suspension semantics, and audited state changes without expanding into a billing engine." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot already resolves plan-profile entitlements for a workspace, but it still lacks one central commercial lifecycle state that explains whether the workspace is in trial, grace, normal paid use, or suspended/read-only posture. +- **Today's failure**: Operators can hit blocked onboarding or reporting actions without one consistent business-state explanation, and a future suspension or grace posture would otherwise be implemented as scattered local conditionals or mistaken as RBAC denial. +- **User-visible improvement**: Platform operators can set one auditable workspace commercial lifecycle state, and tenant/workspace operators then see a truthful allow, warn, or read-only message directly at onboarding and review-pack action surfaces without losing safe access to existing history and evidence. +- **Smallest enterprise-capable version**: Add one platform-managed workspace commercial lifecycle overlay on top of the existing entitlement substrate, resolve four bounded lifecycle states, gate managed-tenant onboarding activation plus review-pack start actions from that central decision, and preserve safe read-only access to existing review/evidence history while suspended. +- **Explicit non-goals**: No payment providers, invoicing, taxes, accounting, checkout, public pricing, website work, customer-account modeling, subscription engine, automated renewal reminders, broad entitlement spread, or customer self-service lifecycle management. +- **Permanent complexity imported**: One bounded lifecycle state family, one small central lifecycle resolution layer on top of the existing entitlement substrate, one platform-side state change surface with audit, and focused unit plus feature coverage. +- **Why now**: This directly extends real repo truth from Spec 247 and `WorkspaceEntitlementResolver`, so it is implementation-ready as a narrow follow-up. Localization remains a broader missing foundation, and external support-desk handoff still lacks a concrete external target. +- **Why not local**: The same commercial posture must drive system support visibility, onboarding activation, review-pack generation, and suspended read-only access rules. Local page checks would drift immediately and recreate the current manual explanation problem. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New state axis, foundation-sounding commercial theme, and multi-surface touchpoint. Defense: this slice is limited to one overlay on top of an existing resolver, one platform mutation surface, two already-real gated behaviors, and explicit read-only preservation instead of a broader billing platform. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/system/directory/workspaces/{workspace}` for platform-side inspection and lifecycle state change + - `/admin/onboarding/{onboardingDraft}` for managed-tenant onboarding activation + - `/admin/reviews` plus existing tenant review detail, tenant dashboard, and review-pack registry/detail surfaces for `Generate pack`, `Regenerate`, and `Export executive pack` + - existing read-only review, evidence, and generated-pack consumption surfaces that must remain available while suspended/read-only +- **Data Ownership**: Commercial lifecycle state remains workspace-owned truth and is stored through the existing workspace settings infrastructure. Existing plan profiles and entitlement decisions from Spec 247 remain the underlying workspace-owned substrate. Tenant-owned review packs, evidence snapshots, review history, and onboarding records stay tenant-owned and are not remodeled by this slice. +- **RBAC**: Platform users with directory visibility plus a dedicated commercial lifecycle management capability may inspect and change state on `/system`. Workspace or tenant members keep their existing onboarding and review-pack capabilities on `/admin`, but lifecycle state is a business-state overlay rather than a self-service setting. Non-members and wrong-plane actors continue to receive 404. Members missing capability continue to receive 403. Members with the required capability but blocked by lifecycle state receive a truthful business-state block instead of an authorization failure. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice does not introduce a new tenantless collection or cross-tenant list. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. The lifecycle overlay never reveals tenant-owned history or artifacts outside the already-authorized workspace and tenant scope. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: status messaging, action gating, system detail controls, operation-start blocking, evidence/report viewers +- **Systems touched**: existing workspace settings persistence, existing workspace entitlement resolution, system workspace detail view, onboarding activation gate, review-pack generation entry family, audit logging, and existing read-only review/evidence/download surfaces +- **Existing pattern(s) to extend**: existing workspace entitlement resolver and summary pattern, existing workspace-setting audit path, existing review-pack start UX, existing onboarding activation gate, and existing system detail summary surfaces +- **Shared contract / presenter / builder / renderer to reuse**: the current workspace entitlement resolution path and its audit-backed settings persistence remain the canonical substrate; this slice adds one bounded commercial lifecycle decision layer on top rather than a second parallel commercial framework +- **Why the existing shared path is sufficient or insufficient**: The current entitlement substrate is already sufficient for plan defaults, overrides, and per-key allow/block decisions. It is insufficient for one workspace-wide lifecycle posture that can say "expansion frozen" or "read-only suspended" consistently across multiple surfaces. +- **Allowed deviation and why**: none. No surface may invent local lifecycle labels, local business-state copy, or page-specific suspension rules. +- **Consistency impact**: State labels, source labels, block reasons, and read-only explanations must mean the same thing on the system workspace page, onboarding completion step, review-pack start actions, and preserved read-only review/evidence surfaces. +- **Review focus**: Reviewers must verify that all in-scope surfaces consume one shared lifecycle decision, that lifecycle overlay semantics do not expand access beyond current entitlements, and that suspended read-only messaging does not drift across surfaces. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: existing review-pack queued-start, `Open operation`, and canonical run-link behavior remain unchanged when lifecycle state allows the start action +- **Delegated start/completion UX behaviors**: queued toast, `Open operation` link, dedupe behavior, and terminal lifecycle feedback stay on the existing review-pack path when allowed. A lifecycle block stops earlier and produces no queued-start feedback because no run is created. +- **Local surface-owned behavior that remains**: local surfaces only render lifecycle state, blocked reason, and the safe next step. They do not replace the existing review-pack run UX. +- **Queued DB-notification policy**: unchanged +- **Terminal notification path**: central lifecycle mechanism for existing review-pack runs only +- **Exception required?**: none + +## 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`)* + +N/A - no shared provider/platform boundary is changed. Commercial lifecycle state is platform-core workspace truth and must remain provider-neutral even when it gates provider-backed review-pack workflows. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | yes | Native Filament system detail page | detail summary, header actions, status messaging | detail page, header action, summary card | no | Single platform mutation surface only | +| Managed tenant onboarding activation gate | yes | Native Filament wizard | action gating, helper text, business-state callout | completion step, confirmation action | no | Reuses the existing activation step | +| Review-pack generation entry family | yes | Native Filament widget/resource/page actions | operation-start gating, helper text, state badges | widget action, detail action, list/header action | no | Only `Generate pack`, `Regenerate`, and `Export executive pack` are in scope | +| Existing read-only review, evidence, and generated-pack consumption surfaces | yes | Native Filament detail and download surfaces | evidence/report viewers, detail messaging | detail page, download action, read-only summary | no | No new routes; the slice only preserves safe read-only availability during suspension | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | Primary Decision Surface | Platform operator decides whether a workspace should remain trial, move into grace, return to active paid, or become suspended/read-only | Current state, rationale, affected action families, and last changed attribution | Existing entitlement summary and related workspace diagnostics | Primary because this is the one place where commercial posture is intentionally changed | Follows platform support/commercial workflow rather than customer admin navigation | Prevents founders or support staff from reconstructing state from ad hoc notes and blocked actions | +| Managed tenant onboarding activation gate | Primary Decision Surface | Workspace operator decides whether the current tenant may be activated now | Lifecycle state, whether activation is allowed, and the business-state reason when blocked | Existing onboarding verification and readiness diagnostics remain secondary | Primary because onboarding completion is the actual high-impact mutation point | Keeps the commercial decision inside the activation workflow | Removes the need to ask support whether a block is about permissions or billing state | +| Review-pack generation entry family | Secondary Context Surface | Reporting operator decides whether to start, retry, or export a review pack from the current tenant or review context | Lifecycle state, whether the start action is blocked, and the safe fallback when suspended/read-only | Existing run state, artifact truth, and review history remain secondary | Not primary because the family exists to continue reporting/review workflows, not to manage commercial posture itself | Stays inside existing report-generation workflows | Avoids a second support lookup just to understand why generation is blocked | +| Existing read-only review, evidence, and generated-pack consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer-safe or operator read-only consumer verifies existing history while the workspace is suspended/read-only | Existing history, evidence, and generated pack availability plus a calm read-only explanation | Raw provider or support diagnostics remain secondary and capability-gated | Not primary because these surfaces answer "what history is still safe to read" rather than "what state should change" | Preserves evidence-first review consumption instead of forcing new export workarounds | Prevents suspended workspaces from looking completely unavailable when history should still be readable | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | support-platform, operator-platform | Current lifecycle state, rationale, last changed attribution, and affected behavior summary | Existing workspace entitlement summary and tenant counts | No raw settings payload or internal debug data by default | `Change commercial state` | Raw settings rows and internal debugging remain hidden | The page states the lifecycle blocker once and reuses the same labels later rather than restating them differently | +| Managed tenant onboarding activation gate | operator-MSP | Activation allowed/blocked, current lifecycle state, and why the block is business-state rather than permission-state | Existing readiness and verification diagnostics already on the wizard | No support/raw payloads on the default path | `Complete onboarding` when allowed, otherwise `Request commercial review` | Deeper commercial diagnostics stay off the onboarding surface | The step shows one lifecycle explanation and does not restate the whole workspace commercial profile | +| Review-pack generation entry family | operator-MSP | Start action availability, current lifecycle state, and the safe fallback when generation is blocked | Existing run state and artifact status | No raw support diagnostics on start surfaces | `Generate pack`, `Regenerate`, or `Export executive pack` when allowed; otherwise `View current pack` | System-only lifecycle controls stay off these surfaces | One shared lifecycle reason is reused across all in-scope start actions | +| Existing read-only review, evidence, and generated-pack consumption surfaces | customer-read-only, operator-MSP | What history remains available and why the workspace is read-only rather than fully inaccessible | Existing review history and artifact provenance | Support/raw details remain collapsed or gated | `View current review` or `Download current pack` | Any mutation affordance stays blocked in suspended/read-only posture | The read-only explanation appears once and later sections add evidence rather than repeating the same blocker | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | System / Detail / Diagnostics | Read-only detail with bounded mutation action | Change the workspace lifecycle state | Dedicated workspace detail page | forbidden | Existing admin-workspace and related navigation stay secondary | `Change commercial state` contains the high-risk `Suspended / read-only` path with explicit confirmation | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity plus current lifecycle state | Commercial lifecycle | Current state, rationale, and affected behaviors | Acceptable detail-surface exception because mutation stays bounded to one header action on the detail page | +| Managed tenant onboarding activation gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because commercial state blocks expansion | In-page completion step | forbidden | Existing back-navigation and tenant links stay secondary | Existing `Cancel draft` and `Delete draft` remain the only destructive actions | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus current tenant | Onboarding commercial state | Activation allowed or blocked and why | Existing wizard exception remains valid | +| Review-pack generation entry family | Contextual action family | Widget/resource/page start actions | Start, retry, or export a review pack when allowed | Explicit action on the current tenant or review context | mixed - existing registry rows may still open detail, but start actions remain explicit | Existing `View` and `Download` stay secondary and outside the blocked start gate | Existing destructive actions remain out of scope and keep current placement | `/admin/reviews` plus existing tenant review-pack collection surfaces | Existing tenant review detail and review-pack detail surfaces | Active workspace, active tenant, review or pack context | Review-pack generation | Start allowed or blocked, and the safe read-only fallback | Grouped-action family exception is documented here so all start actions share one gate | +| Existing read-only review, evidence, and generated-pack consumption surfaces | Detail / Report viewer / Download | Read-only detail and artifact consumption | View history or download an already-generated pack | Existing review or pack detail page | allowed where the current collection already opens detail | Supporting navigation remains secondary | none | Existing review and review-pack collections | Existing review, evidence, and review-pack detail routes | Active workspace, active tenant, current artifact or review | Review history / Generated pack | Safe read-only availability during suspension | No new surface type introduced | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | Platform commercial or support operator | Decide the current commercial posture of a workspace | System detail page | What lifecycle state should this workspace be in now? | State, rationale, affected behaviors, and last changed attribution | Existing entitlement summary and workspace diagnostics | commercial lifecycle, entitlement substrate | TenantPilot only | Change commercial state | Set suspended/read-only | +| Managed tenant onboarding activation gate | Workspace owner or manager completing onboarding | Decide whether the current tenant can be activated now | Guided workflow step | Can I activate this tenant under the current commercial posture? | Current lifecycle state, whether activation is allowed, and the block reason when not | Existing verification and bootstrap detail | onboarding readiness, commercial lifecycle, entitlement availability | TenantPilot only for activation state | Complete onboarding | Cancel draft, Delete draft | +| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether a new pack run may start now | Contextual start-action family | Can I start, retry, or export a pack from this context? | Current lifecycle state, whether the start action is blocked, and the safe fallback | Existing run state, review status, and artifact truth | commercial lifecycle, entitlement availability, run state, artifact status | TenantPilot only until the existing run starts | Generate pack, Regenerate, Export executive pack, View current pack | Existing destructive actions remain unchanged and out of scope | +| Existing read-only review, evidence, and generated-pack consumption surfaces | Customer-safe reader or workspace operator | Consume already-generated history safely while the workspace is read-only | Read-only detail and download surfaces | What history can I still read or download safely? | Existing review/evidence/download truth plus a calm read-only explanation | Raw provider diagnostics and support-only detail | commercial lifecycle, artifact availability | none | View current review, Download current pack | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes - one workspace-owned commercial lifecycle state becomes current-release business truth, but it is stored through existing workspace settings rather than a new table +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded lifecycle resolution layer on top of the existing entitlement substrate +- **New enum/state/reason family?**: yes - the four-state lifecycle family (`trial`, `grace`, `active_paid`, `suspended_read_only`) +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Support and operators cannot truthfully explain whether a workspace is in a normal commercial state, an expansion freeze, or a read-only suspension without reconstructing the answer from scattered surface behavior. +- **Existing structure is insufficient because**: Spec 247 gives per-key entitlement truth, but it does not provide one workspace-wide lifecycle posture that can say "activation blocked but reading is still safe" or "new runs blocked while history remains available." +- **Narrowest correct implementation**: Keep persistence inside the existing workspace settings infrastructure, add one small state family and one shared resolution layer, mutate it from one system detail page, and apply it only to two already-real start behaviors plus suspended read-only preservation. +- **Ownership cost**: One state vocabulary, one additional decision layer, cross-surface copy discipline, and focused tests for state transitions plus allowed/blocked behavior. +- **Alternative intentionally rejected**: A new subscription/customer-account model or many per-surface lifecycle flags was rejected because the repo has no current billing domain and the smallest safe slice only needs one central commercial posture. +- **Release truth**: current-release truth with later follow-up candidates for automation, billing integration, and broader lifecycle-aware entitlement spread + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage proves default state resolution, state precedence over existing entitlements, and state-to-behavior mapping. Focused feature coverage proves platform mutation, audit logging, onboarding blocks, review-pack start blocks, and preserved read-only access without expanding into browser or heavy-governance lanes. +- **New or expanded test families**: one bounded lifecycle resolver unit family plus focused extensions to the existing system detail, onboarding, review-pack, and preserved read-only feature families +- **Fixture / helper cost impact**: Add only workspace, platform user, workspace member, onboarding draft, active tenant count, existing review pack, and existing evidence/history fixtures required to prove the state consequences. Avoid payment-provider mocks, browser harnesses, or new heavy support fixtures. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page +- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the system detail mutation surface and onboarding gate. Review-pack gating still needs monitoring-state assertions to prove blocked starts create no run, while suspended read-only preservation needs one detail/download assertion on existing artifact surfaces. +- **Reviewer handoff**: Reviewers must confirm that lifecycle blocks remain distinct from 404 and 403 outcomes, that source labels stay consistent on the system detail surface, that `grace` and `suspended_read_only` do not collapse into one behavior, that blocked review-pack starts create no queued or terminal notification, that already queued or running review-pack runs remain unaffected by later suspension, and that existing read-only history/download access remains available under current RBAC. +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- One central workspace commercial lifecycle overlay with exactly four states: `trial`, `grace`, `active_paid`, and `suspended_read_only` +- One platform-managed lifecycle change path with rationale and audit, persisted through the existing workspace settings infrastructure +- One shared lifecycle resolution path layered on top of the existing Spec 247 entitlement substrate +- Lifecycle gating of managed-tenant onboarding activation +- Lifecycle gating of review-pack `Generate pack`, `Regenerate`, and `Export executive pack` entry points +- Suspended/read-only preservation of authorized review history, evidence, and already-generated review-pack consumption +- Explicit business-state messaging that distinguishes lifecycle blocks from RBAC failures + +### Non-Goals + +- Payment providers, invoices, taxes, accounting, checkout, public pricing, website work, and payment failure workflows +- New customer-account, subscription, contract, or offer models +- Automated timers, expiries, reminders, or scheduled state transitions +- Customer self-service state changes from the workspace admin plane +- Broad entitlement expansion across seats, exports, retention, support SLAs, or unrelated feature flags +- Broad suspension logic across every mutable surface in the product +- A second commercial control plane outside the existing system workspace detail flow + +## Assumptions + +- Spec 247 remains the canonical entitlement substrate. Commercial lifecycle state is an overlay that can warn or restrict, not a replacement for plan-profile and per-key entitlement truth. +- Commercial lifecycle mutation is platform-managed in this slice. Workspace and tenant operators may observe the resulting state where it matters, but they do not change it themselves. +- If no explicit lifecycle state has been set for a workspace, the system resolves to `active_paid` so that current repo behavior stays unchanged until a platform operator intentionally selects a different state. +- `grace` is intentionally narrower than `suspended_read_only`: it freezes new managed-tenant activation but continues to allow existing review-pack start behavior when the underlying entitlement substrate still allows it. +- `suspended_read_only` preserves existing review/evidence/download access under current RBAC and redaction rules, but blocks new onboarding activation and new review-pack start actions. + +## Risks + +- `grace` and `suspended_read_only` can drift into near-duplicates if blocked-action copy and tests do not keep their consequences distinct. +- A later customer-account or billing source could require revisiting how manual lifecycle transitions are sourced, even though that broader domain is intentionally out of scope here. +- A future admin-plane commercial settings surface could confuse ownership if it appears without preserving platform-only mutation authority. +- Mid-flight review-pack runs created before a workspace becomes suspended could create confusion if the product does not clearly state that this slice only blocks future starts. + +## Deferred Adjacent Candidates + +- **Localization v1** remains a separate, broader foundation candidate because it requires cross-product locale resolution and copy governance beyond this bounded commercial lifecycle slice. +- **External Support Desk / PSA Handoff** remains a separate candidate because repo docs still do not define one concrete external desk target to hand off into. +- Broader billing lifecycle automation, reminders, and external billing-source integration stay deferred until a real account and payment domain exists in repo truth. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Set one workspace commercial lifecycle state centrally (Priority: P1) + +As a platform commercial or support operator, I want to set a workspace's current commercial lifecycle state once so downstream product behavior follows one audited source of truth instead of local exceptions. + +**Why this priority**: Without one central lifecycle state, every later gate or support explanation would duplicate commercial truth and drift away from the already-real entitlement substrate. + +**Independent Test**: Open the existing system workspace detail surface, change the lifecycle state with rationale, and verify that the new state is visible there and auditable without touching onboarding or reporting flows. + +**Acceptance Scenarios**: + +1. **Given** a workspace has no explicit commercial lifecycle state, **When** an authorized platform operator sets it to `trial` with rationale, **Then** the workspace resolves to `trial`, the change is auditable, and the system detail surface shows the new state and rationale. +2. **Given** a workspace is currently in `grace`, **When** an authorized platform operator changes it to `suspended_read_only`, **Then** the previous state is replaced, the new state is auditable, and later gated surfaces consume the new state. +3. **Given** a workspace is in `suspended_read_only`, **When** an authorized platform operator returns it to `active_paid`, **Then** future gated actions again use the normal underlying entitlement substrate instead of the suspended overlay. + +--- + +### User Story 2 - Truthfully block tenant activation when lifecycle state freezes expansion (Priority: P1) + +As an authorized workspace operator, I want the onboarding completion step to tell me whether the tenant may be activated under the current commercial lifecycle state so I can distinguish business-state blocking from permissions or onboarding readiness problems. + +**Why this priority**: Managed-tenant activation is the highest-risk first-slice mutation and the clearest place where a grace or suspended posture must stop expansion without ambiguity. + +**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that the same action becomes allowed or blocked with the correct business-state explanation before any activation mutation happens. + +**Acceptance Scenarios**: + +1. **Given** a workspace is `trial` or `active_paid` and the existing entitlement substrate allows activation, **When** an authorized operator reaches the onboarding completion step, **Then** the step allows completion and no lifecycle block is shown. +2. **Given** a workspace is in `grace`, **When** the same operator reaches the completion step, **Then** the action remains visible but blocked with a business-state explanation that new managed-tenant activation is frozen during grace. +3. **Given** a workspace is in `suspended_read_only`, **When** the operator reaches the same step, **Then** activation is blocked before any tenant mutation occurs and the step explains that the workspace is read-only rather than lacking permission. + +--- + +### User Story 3 - Block new review-pack starts while preserving safe historical access (Priority: P2) + +As a reporting operator or customer-safe reader, I want new review-pack start actions to obey the current commercial lifecycle state while already-generated history remains safely readable so suspension does not erase needed evidence. + +**Why this priority**: Review-pack generation already exists on multiple real surfaces, and suspension is only trustworthy if it blocks new starts consistently while preserving safe access to history and already-generated evidence. + +**Independent Test**: Seed a workspace with an existing generated pack and history, switch it to `suspended_read_only`, verify that `Generate pack`, `Regenerate`, and `Export executive pack` stop before any new run or artifact is created, that blocked starts emit no queued or terminal review-pack notification, that already queued or running review-pack work continues unchanged, and then confirm that authorized readers can still view or download the already-generated artifacts. + +**Acceptance Scenarios**: + +1. **Given** a workspace is `active_paid` or `trial` and the existing review-pack entitlement allows generation, **When** an authorized operator starts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the current review-pack flow continues unchanged. +2. **Given** a workspace is in `grace` and the underlying review-pack entitlement allows generation, **When** an authorized operator starts the same action, **Then** the action remains allowed with a grace warning and without blocking the run. +3. **Given** a workspace is in `suspended_read_only`, **When** an authorized operator attempts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the action is blocked before any new `ReviewPack` or `OperationRun` is created and no queued or terminal review-pack notification is emitted for the blocked attempt. +4. **Given** a review-pack run was already created while the workspace lifecycle state still allowed it, **When** the workspace later moves to `suspended_read_only`, **Then** the existing queued or running review-pack work may complete unchanged because this slice only blocks future start attempts. +5. **Given** a workspace is in `suspended_read_only` and already has generated review packs, evidence, or review history, **When** an authorized reader opens or downloads those existing artifacts, **Then** the existing read-only access continues under current RBAC and redaction rules. + +### Edge Cases + +- A workspace with no explicit lifecycle state must still resolve deterministically to `active_paid` so current Spec 247 behavior does not change accidentally. +- If the lifecycle state allows a behavior but the underlying entitlement substrate blocks it, the underlying entitlement block still applies and must remain distinguishable from lifecycle blocking. +- If the lifecycle state becomes `suspended_read_only` while a review-pack run is already queued or running, the existing run may complete; the new state only blocks future start attempts in this slice. +- A workspace member who lacks the relevant onboarding or review-pack capability must still receive 403 even when the workspace lifecycle state is otherwise permissive. +- A non-member or wrong-plane actor must not learn whether a workspace is in `grace` or `suspended_read_only`; those requests continue to resolve as 404. +- Suspended/read-only behavior must never revoke access to already-generated artifacts or review/evidence history that the actor is otherwise allowed to read. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes runtime behavior and writes workspace-owned commercial state, but it adds no Microsoft Graph calls, no new provider dispatch path, and no new queued workflow family. Lifecycle state changes use the existing workspace settings infrastructure and audit foundation. Existing review-pack `OperationRun` behavior is reused only when lifecycle state allows a start action. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new business-state family because current-release operator workflows now need a workspace-wide commercial posture that per-key entitlement decisions cannot express alone. A narrower local-only approach would still scatter lifecycle semantics across onboarding and review-pack surfaces. + +**Constitution alignment (XCUT-001):** All in-scope gated behaviors and preserved read-only surfaces must consume the same lifecycle decision. No local page is allowed to invent its own trial, grace, or suspension semantics. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Blocked onboarding and blocked review-pack starts must show customer-safe or operator-safe default content first, with diagnostics and support-only detail remaining secondary. Suspended read-only surfaces must preserve one calm next step instead of turning history surfaces into error pages. + +**Constitution alignment (PROV-001):** Commercial lifecycle state is platform-core workspace truth and must not import provider-specific vocabulary or billing-provider semantics. + +**Constitution alignment (TEST-GOV-001):** Proof remains in focused unit plus feature lanes. New fixtures stay limited to workspace, platform operator, workspace member, onboarding draft, tenant count, and existing review-pack/evidence artifacts. + +**Constitution alignment (OPS-UX):** This feature does not create a new run family. Existing review-pack generation keeps the current queued toast, operation link, and terminal notification path when lifecycle state allows it. Blocked lifecycle starts create no run and no run lifecycle feedback. + +**Constitution alignment (OPS-UX-START-001):** Lifecycle gating sits before review-pack run creation and delegates all allowed queued-start UX to the existing shared review-pack path. + +**Constitution alignment (RBAC-UX):** Two authorization planes are involved: platform `/system` for lifecycle mutation and tenant/admin `/admin` for contextual blocked-or-allowed behavior. Wrong-plane or non-member requests remain 404. Members missing capability remain 403. Lifecycle blocking is a product-state response for otherwise-authorized actors and must not masquerade as authorization failure. + +**Constitution alignment (BADGE-001):** If lifecycle badges or state chips are rendered, their labels and visual semantics must come from one shared lifecycle vocabulary rather than page-local color mapping. + +**Constitution alignment (UI-FIL-001):** The slice must extend existing native Filament detail, wizard, widget, resource, and download surfaces. No custom commercial panel or page-local status language is allowed. + +**Constitution alignment (UI-NAMING-001):** Primary labels remain product-facing and specific: `Trial`, `Grace`, `Active paid`, `Suspended / read-only`, `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. Billing-provider or checkout terminology remains out of scope. + +**Constitution alignment (DECIDE-001):** The system workspace detail page is the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that only show the commercial truth required for the immediate action. Existing history/evidence surfaces remain tertiary read-only contexts. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing system detail, guided onboarding, grouped review-pack actions, and read-only artifact consumption patterns. It may not create a second admin-plane commercial management surface or redundant inspect actions. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Lifecycle mutation stays on the system workspace detail page. Onboarding completion remains the primary activation action. Review-pack start actions remain the primary reporting mutations where they already exist. View/download history remains secondary but available during suspension. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin lifecycle overlay is justified because direct reads from the existing entitlement substrate cannot express one workspace-wide read-only posture. Tests must prove business outcomes such as allowed, warned, blocked, and preserved-read behavior rather than badge rendering alone. + +**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with the documented system detail exception for one bounded mutation action, the existing onboarding wizard exception, and the existing review-pack action family. No empty action groups or redundant view actions are introduced by this slice. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature extends the existing system detail, onboarding, and review-pack surfaces with bounded state information only. It does not create a new commercial page shell or duplicate summary screen. + +### Functional Requirements + +- **FR-251-001 Central lifecycle state**: The system MUST resolve one commercial lifecycle state per workspace with exactly four values: `trial`, `grace`, `active_paid`, and `suspended_read_only`. +- **FR-251-002 Existing entitlement substrate remains canonical**: The system MUST layer lifecycle state on top of the existing Spec 247 entitlement substrate rather than replacing plan profiles, entitlement keys, or override logic. +- **FR-251-003 Deterministic default**: If no explicit lifecycle state has been stored for a workspace, the system MUST resolve to `active_paid` so existing behavior remains unchanged until an operator intentionally changes state. +- **FR-251-004 Workspace-owned persistence**: The system MUST store lifecycle state and rationale through the existing workspace settings infrastructure instead of introducing a new customer-account, subscription, or billing table. +- **FR-251-005 Platform-managed mutation**: Only authorized platform users MAY change or override lifecycle state in this slice, and the workspace or tenant admin plane MUST NOT become a self-service lifecycle control surface. +- **FR-251-006 Decision shape**: The effective lifecycle decision MUST include the state, source, operator-visible rationale, last changed attribution, and a summary of which in-scope behaviors are currently warned, allowed, or blocked. +- **FR-251-007 State precedence**: Lifecycle state MUST apply after the existing entitlement substrate and MAY only warn or restrict. It MUST NOT expand access beyond what the underlying entitlement decision already allows. +- **FR-251-008 Onboarding activation gate**: Managed-tenant onboarding activation MUST consult the shared lifecycle decision before mutation. `grace` and `suspended_read_only` MUST block activation before any tenant activation state changes occur. +- **FR-251-009 Review-pack start gate**: `Generate pack`, `Regenerate`, and `Export executive pack` MUST consult the shared lifecycle decision before creating or reusing a `ReviewPack` or `OperationRun`. `suspended_read_only` MUST block those actions before any new run or artifact start occurs. +- **FR-251-010 Grace semantics**: `grace` MUST have a distinct behavioral consequence from `active_paid` by freezing new managed-tenant onboarding activation while leaving in-scope review-pack start behavior under the existing entitlement substrate. +- **FR-251-011 Suspended read-only semantics**: `suspended_read_only` MUST block onboarding activation and review-pack start actions while preserving authorized read-only access to existing review history, evidence, and already-generated review-pack consumption. +- **FR-251-012 In-flight behavior boundary**: A lifecycle state change to `suspended_read_only` MUST affect future start attempts only in this slice and MUST NOT retroactively cancel already-created review-pack runs. +- **FR-251-013 Message semantics**: Gated surfaces MUST clearly distinguish lifecycle business-state blocking from entitlement-limit blocking and from authorization failure. +- **FR-251-014 System visibility**: The system workspace detail surface MUST show the current lifecycle state, rationale, affected behavior summary, and last changed attribution to authorized platform users. +- **FR-251-015 Auditability**: Every lifecycle state change and manual override MUST create an auditable record containing old state, new state, actor, and rationale. +- **FR-251-016 No scattered lifecycle conditionals**: Onboarding, review-pack generation, and preserved read-only surfaces MUST use the shared lifecycle decision rather than local page-specific commercial booleans. +- **FR-251-017 Bounded non-goals**: This slice MUST NOT introduce payment providers, invoices, taxes, accounting, checkout, public pricing, website work, customer-account modeling, broad billing automation, or broad entitlement spread beyond the in-scope behaviors above. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | existing system workspace detail surface | none on collection | dedicated detail route | none | none | N/A | `Change commercial state` with bounded state selection and rationale; `Suspended / read-only` path requires explicit confirmation | N/A | yes | Existing system-detail exception remains bounded to one platform mutation surface | +| Managed tenant onboarding activation gate | existing onboarding wizard completion step | existing back-navigation and tenant links | N/A - guided workflow | none | none | existing onboarding start state unchanged | `Complete onboarding` remains the primary action and becomes lifecycle-gated | N/A | yes - existing onboarding activation audit path | Existing wizard exception remains valid | +| Review-pack generation entry family | existing tenant dashboard, review register, tenant review detail, and review-pack detail/registry surfaces | current `Generate pack`, `Regenerate`, and `Export executive pack` actions stay primary where already present | existing registry/detail affordances remain unchanged | existing `View` or `Download` shortcuts remain secondary where already present | none | existing `Generate` CTA remains where already present | existing start actions are lifecycle-gated; `View` and `Download` remain outside the blocked-start gate | N/A | no new audit requirement for blocked attempts | Grouped action family stays consistent and does not invent new local start actions | +| Existing read-only review, evidence, and generated-pack consumption surfaces | existing review/evidence/detail/download surfaces | none | existing detail routes | existing `View` or `Download` actions remain available under current RBAC | none | N/A | existing read-only view/download actions remain available during suspension | N/A | no new audit action; read-only continuation only | No new surface is created; the slice only preserves availability semantics | + +### Key Entities *(include if feature involves data)* + +- **Workspace Commercial Lifecycle Setting**: Workspace-owned commercial posture consisting of lifecycle state, rationale, and last change attribution, persisted through the existing workspace settings infrastructure. +- **Effective Commercial Lifecycle Decision**: Derived decision that overlays the existing entitlement substrate and answers whether in-scope behaviors are allowed, warned, or blocked, plus why. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Authorized platform operators can inspect and change a workspace commercial lifecycle state from one system workspace detail surface and see the updated state plus rationale immediately afterward. +- **SC-002**: Authorized workspace operators can determine in under 30 seconds whether onboarding activation or review-pack start is blocked by commercial state rather than by missing permission or underlying entitlement limits. +- **SC-003**: 100% of `suspended_read_only` blocked onboarding or review-pack start attempts stop before activation mutation or new run/artifact creation, while authorized readers still retain access to already-generated history and evidence. +- **SC-004**: Every commercial lifecycle state change produces one auditable old-state to new-state record with actor and rationale, and platform support can inspect that state from one canonical system surface. diff --git a/specs/251-commercial-entitlements-billing-state/tasks.md b/specs/251-commercial-entitlements-billing-state/tasks.md new file mode 100644 index 00000000..e675385f --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/tasks.md @@ -0,0 +1,190 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Commercial Entitlements and Billing-State Maturity + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts. + +## Test Governance Notes + +- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for resolver precedence, system-plane mutation, onboarding gating, review-pack start blocking, and preserved suspended read-only continuation. +- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/` plus focused `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/`; do not widen this slice into browser or heavy-governance families. +- Reuse existing workspace, platform-user, workspace-member, onboarding-draft, tenant, review-pack, and evidence fixtures; any new helper or factory state must stay opt-in and cheap by default. +- If implementation needs a bounded exception for blocked-decision transport or preserved read-only scope, record `document-in-feature` or `follow-up-spec` in the final close-out task instead of widening feature scope. + +## Scope Control Notes + +- Keep implementation inside one commercial lifecycle overlay, one system-plane lifecycle mutation surface, managed-tenant onboarding activation gating, review-pack generation/regeneration/export gating, and preserved read-only review/evidence/download semantics while suspended. +- Do not add payment provider, invoicing, checkout, website, customer-account, localization, external support-desk handoff, or broad billing-platform work. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Lock the bounded slice, contract semantics, and validation plan before runtime edits begin. + +- [x] T001 Review the bounded slice, explicit non-goals, scope-control decisions, and review outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` +- [x] T002 [P] Review the lifecycle-state model, system/admin split, preserved read-only contract, and 404 versus 403 versus business-state semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml` +- [x] T003 [P] Confirm the focused Sail/Pest proof commands and reviewer scenarios in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared lifecycle primitives that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Register the commercial lifecycle state and rationale setting definitions, validation metadata, and operator-facing labels in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php` +- [x] T005 [P] Add the bounded four-state catalog, action-decision matrix, and shared overlay resolution logic in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` +- [x] T006 Thread lifecycle setting resolution, default `active_paid` fallback, and lifecycle change attribution through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` + +**Checkpoint**: Foundation ready. User story work can now proceed independently without inventing local lifecycle state. + +--- + +## Phase 3: User Story 1 - Set Workspace Commercial State Centrally (Priority: P1) 🎯 MVP + +**Goal**: Let an authorized platform operator inspect and change one workspace commercial lifecycle state from the existing system workspace detail surface. + +**Independent Test**: Open `/system/directory/workspaces/{workspace}` as an authorized and unauthorized platform actor, change the lifecycle state with rationale, and verify the page shows current state, affected behavior summary, last-changed attribution, and audit-backed mutation semantics without creating a second control plane. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Add unit coverage for default `active_paid` fallback, explicit stored states, `default_active_paid` versus `workspace_setting` source resolution, grace versus suspended action outcomes, and last-change attribution in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` +- [x] T008 [P] [US1] Extend system-plane feature coverage for lifecycle summary and source-label rendering, capability-gated mutation, confirmation plus rationale validation for every explicit transition, and 404 versus 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` + +### Implementation for User Story 1 + +- [x] T009 [US1] Add the dedicated commercial-lifecycle management capability and apply it to the system workspace detail action surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/PlatformCapabilities.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` +- [x] T010 [US1] Project the shared lifecycle state, source label, rationale, affected-behavior summary, and last-changed attribution onto `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php` +- [x] T011 [US1] Add the confirmation-protected `Change commercial state` action with audited old/new state writes and rationale validation for every explicit lifecycle transition in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php` + +**Checkpoint**: User Story 1 is independently functional when the system plane exposes one canonical lifecycle decision and one audited mutation path. + +--- + +## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1) + +**Goal**: Keep onboarding completion visible to otherwise authorized workspace actors while blocking activation with business-state truth when `grace` or `suspended_read_only` freezes expansion. + +**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that activation is either allowed or blocked with the correct lifecycle explanation before any tenant activation mutation occurs. + +### Tests for User Story 2 + +- [x] T012 [P] [US2] Extend onboarding feature coverage for trial/active allow, grace block, suspended block, and 404 versus 403 versus business-state outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` + +### Implementation for User Story 2 + +- [x] T013 [US2] Project the shared lifecycle decision onto the onboarding completion step and helper text in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T014 [US2] Enforce lifecycle blocking before any tenant activation mutation or onboarding completion audit path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T015 [US2] Keep grace and suspended explanations distinct from entitlement-limit and authorization failures by sourcing block messaging from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` + +**Checkpoint**: User Story 2 is independently functional when onboarding activation exposes one truthful lifecycle decision and never mutates tenant state after a commercial-state block. + +--- + +## Phase 5: User Story 3 - Block New Review-Pack Starts While Preserving Read-Only History (Priority: P2) + +**Goal**: Reuse one lifecycle decision for `Generate pack`, `Regenerate`, and `Export executive pack` while keeping current review, evidence, and already-generated pack consumption available under existing RBAC during suspension. + +**Independent Test**: Switch a workspace with existing review history, evidence, and generated packs to `suspended_read_only`, verify that all in-scope start actions block before any new `ReviewPack` or `OperationRun` write occurs, and confirm that authorized actors can still view or download existing artifacts. + +### Tests for User Story 3 + +- [x] T016 [P] [US3] Extend review-pack feature coverage for allowed `trial`/`active_paid`, warned-but-allowed `grace` starts, blocked `suspended_read_only` starts, no new `ReviewPack` or `OperationRun` writes, no queued or terminal notification on blocked starts, and already queued or running review-pack work remaining unaffected by later suspension in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php` +- [x] T017 [P] [US3] Extend suspended read-only consumption coverage for customer review workspace access, current pack download, and evidence snapshot detail access in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + +### Implementation for User Story 3 + +- [x] T018 [US3] Enforce lifecycle gating before any new `ReviewPack`, `OperationRun`, or blocked-start notification path and reuse the existing blocked-decision transport instead of adding a second exception path while leaving already-created runs unaffected in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php` +- [x] T019 [P] [US3] Project lifecycle allow/warn/block messaging onto the tenant dashboard and review register start surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` +- [x] T020 [P] [US3] Gate `Generate pack`, `Regenerate`, and `Export executive pack` actions while keeping `View` and `Download` affordances unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` +- [x] T021 [US3] Preserve suspended read-only review history, evidence, and generated-pack consumption without widening into a broader suspension sweep in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` + +**Checkpoint**: User Story 3 is independently functional when all in-scope start actions share one lifecycle gate and suspended workspaces still retain safe read-only access to existing history and evidence. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope. + +- [x] T022 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` +- [x] T023 Run the targeted system-plane and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` +- [x] T024 Run the targeted review-pack, blocked-start no-notification, in-flight-boundary, and preserved-read-only Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` +- [x] T025 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` +- [x] T026 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` or `follow-up-spec` note for blocked-decision transport or preserved read-only scope in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: starts immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after the shared lifecycle setting and resolver primitives exist. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: first shippable increment once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2 and should follow US1 in the main implementation loop because the system-plane lifecycle vocabulary and audit semantics become canonical there. +- **US3 (P2)**: independently testable after Phase 2 and should merge after US1 because review-pack surfaces must reuse the same lifecycle vocabulary and blocked-decision transport. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap before implementation. +- Complete the shared service or enforcement seam before wiring multiple UI entry points that depend on it. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T002 and T003 can run in parallel after T001 confirms the bounded slice. + +### Phase 2 + +- T004 and T005 can run in parallel. +- T006 should follow once the lifecycle setting keys and resolver shape exist. + +### User Story 1 + +- T007 and T008 can run in parallel. +- T009 can proceed before T010 and T011, but T010 and T011 should stay coordinated because both touch `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`. + +### User Story 2 + +- T012 can run in parallel with any remaining US1 validation once Phase 2 is complete. +- T013, T014, and T015 should stay sequential because they all tighten the same onboarding completion boundary. + +### User Story 3 + +- T016 and T017 can run in parallel. +- After T018 establishes the service-level gate, T019 and T020 can run in parallel. +- T021 should follow the shared start-gate work so preserved read-only semantics stay bounded to existing consumption surfaces. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **Phase 2 + User Story 1 + User Story 2**. This is the smallest slice that creates canonical lifecycle truth, exposes the one platform-side mutation surface, and proves a real business-state consequence (`grace` / `suspended_read_only` onboarding activation gating) without yet widening into review-pack and preserved-history follow-up. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate system-plane lifecycle mutation plus audit semantics. +3. Deliver US2 and validate onboarding business-state gating. +4. Deliver US3 and validate review-pack start blocking plus preserved suspended read-only history/evidence/download access. +5. Finish with Phase 6 validation, formatting, and feature-local close-out recording. \ No newline at end of file diff --git a/specs/252-platform-localization-v1/checklists/requirements.md b/specs/252-platform-localization-v1/checklists/requirements.md new file mode 100644 index 00000000..3e071012 --- /dev/null +++ b/specs/252-platform-localization-v1/checklists/requirements.md @@ -0,0 +1,60 @@ +# Specification Quality Checklist: Platform Localization v1 (DE/EN) + +**Purpose**: Validate specification completeness and quality before proceeding to implementation planning +**Created**: 2026-04-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Business value and operator outcomes stay explicit +- [x] Locale precedence, persistence ownership, and invariance boundaries are explicit +- [x] Runtime-governance sections are present for an implementation-ready spec package +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Acceptance scenarios are defined for the primary user journeys +- [x] Edge cases are identified +- [x] Scope is clearly bounded to platform runtime localization, not website or broad documentation translation +- [x] Dependencies and assumptions are identified + +## Feature Readiness + +- [x] The first slice is small enough for a bounded implementation loop +- [x] The plan identifies the concrete repo surfaces likely to change +- [x] The tasks are ordered, testable, and grouped by user story +- [x] No unresolved product question blocks safe implementation of the first slice; system-panel scope is explicitly limited to explicit override plus system default in v1 + +## Governance Readiness + +- [x] New persistence is justified and remains minimal +- [x] Provider-boundary handling and glossary reuse are explicit +- [x] Existing RBAC and tenant/workspace isolation remain authoritative +- [x] Operator-facing surface changes include the required UI contract sections +- [x] Livewire v4 compliance, unchanged provider registration location, unchanged global-search semantics, no destructive-action additions, and unchanged asset strategy are explicit in the package +- [x] Export, audit, raw payload, and machine-readable invariance is explicit + +## UI / Surface Review Gate + +- [x] Applicability is explicit: this feature changes operator-facing shell, governance, monitoring, and customer-safe viewer surfaces, so a full review gate applies +- [x] Spec, plan, and tasks carry forward the same mixed native/custom classification, shared-family relevance, state-layer ownership, and no-current-exception posture +- [x] The slice stays native/shared-primitives first: one shared context bar, one workspace settings path, one locale resolver, and no second shell or page-local locale system +- [x] Repository signal handling is explicit as `review-mandatory`, with no current exception path or hidden parallel UX language +- [x] Required test-profile depth is explicit: `global-context-shell`, `standard-native-filament`, and `shared-detail-family`, with focused proof commands only +- [x] Audience-aware disclosure remains intact: localization changes decision-first UI copy, while support/raw payloads and machine-readable artifacts remain hidden or invariant + +## Review Outcome + +- [x] Review outcome class chosen: `acceptable-special-case` +- [x] Workflow outcome chosen: `keep` +- [x] Final note location is explicit: any implementation-era translation exceptions are recorded in the active feature close-out task `T022`; the prep package itself needs no current exception note + +## Notes + +- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/`, and `tasks.md`. +- The active slice stays bounded to one locale foundation, two supported locales, one workspace-bound personal preference path, one workspace default path, system-panel explicit-override support only, and first-wave translation coverage for the most visible runtime surfaces. +- Current review outcome is `acceptable-special-case / keep` because the package is intentionally broad across surfaces but remains bounded to one shared locale foundation and one first-wave translation inventory. +- Implementation close-out on 2026-04-28 completed the targeted fast-feedback/confidence Pest lanes, dirty Pint, browser smoke, and post-implementation analysis/fix loop. Any remaining English text is documented as broader pre-existing localization debt outside the bounded first-wave slice, not as an open blocker for this spec. diff --git a/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml b/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml new file mode 100644 index 00000000..2c76a5c6 --- /dev/null +++ b/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml @@ -0,0 +1,177 @@ +openapi: 3.1.0 +info: + title: Platform Localization Logical Contract + version: 0.1.0 + summary: Logical contract for locale resolution, preference persistence, and invariant machine-format behavior. +paths: + /localization/context: + get: + summary: Resolve the effective locale for the current request. + operationId: resolveLocalizationContext + description: Admin and tenant planes may resolve from explicit override, user preference, workspace default, or system default. The system plane resolves from explicit override or system default only in v1. + responses: + '200': + description: Effective locale context for the current request. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedLocaleContext' + /localization/override: + put: + summary: Set or replace the explicit temporary locale override that sits first in the precedence chain. + operationId: updateExplicitLocaleOverride + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LocaleOverrideUpdate' + responses: + '200': + description: Updated locale context after setting the override. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedLocaleContext' + '400': + description: Unsupported or malformed locale input was rejected and the request falls back safely. + delete: + summary: Clear the explicit temporary locale override and return to inherited behavior. + operationId: clearExplicitLocaleOverride + responses: + '204': + description: Explicit override cleared. + /users/me/locale-preference: + put: + summary: Persist the authenticated user's personal locale preference. + operationId: updateUserLocalePreference + description: Applies to the workspace-bound `User` actor on admin and tenant planes only. System-panel `PlatformUser` actors do not get a persisted locale preference in v1. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserLocalePreferenceUpdate' + responses: + '200': + description: Updated locale context after saving the preference. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedLocaleContext' + '400': + description: Unsupported or malformed locale input was rejected and the request falls back safely. + '403': + description: Caller is authenticated but the current surface or policy does not allow personal locale preference mutation. + '404': + description: The personal preference path is unavailable in the current plane or membership context, including system-panel requests. + /workspaces/{workspaceId}/settings/localization/default-locale: + put: + summary: Persist the workspace-owned default locale through the existing settings surface. + operationId: updateWorkspaceDefaultLocale + description: Applies to workspace-scoped admin and tenant flows only. The system plane does not inherit workspace default in v1. + parameters: + - name: workspaceId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceDefaultLocaleUpdate' + responses: + '200': + description: Updated workspace default locale metadata. + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceLocaleSetting' + '400': + description: Unsupported or malformed locale input was rejected. + '403': + description: Caller is a workspace member but lacks permission to manage workspace settings. + '404': + description: Workspace is inaccessible in the current plane or membership context. +components: + schemas: + SupportedLocale: + type: string + enum: + - en + - de + LocaleSource: + type: string + enum: + - explicit_override + - user_preference + - workspace_default + - system_default + ResolvedLocaleContext: + type: object + required: + - locale + - source + - fallback_locale + - machine_artifacts_invariant + properties: + locale: + $ref: '#/components/schemas/SupportedLocale' + source: + $ref: '#/components/schemas/LocaleSource' + fallback_locale: + type: string + const: en + user_preference_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + workspace_default_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + machine_artifacts_invariant: + type: boolean + const: true + UserLocalePreferenceUpdate: + type: object + required: + - preferred_locale + properties: + preferred_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + description: Null clears the personal preference and returns the user to inherited behavior. + LocaleOverrideUpdate: + type: object + required: + - override_locale + properties: + override_locale: + $ref: '#/components/schemas/SupportedLocale' + description: Sets the explicit temporary override that takes precedence over persisted preference and workspace default. + WorkspaceDefaultLocaleUpdate: + type: object + required: + - default_locale + properties: + default_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + description: Null returns the workspace to system-default inheritance. + WorkspaceLocaleSetting: + type: object + required: + - workspace_id + - default_locale + properties: + workspace_id: + type: integer + default_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' \ No newline at end of file diff --git a/specs/252-platform-localization-v1/data-model.md b/specs/252-platform-localization-v1/data-model.md new file mode 100644 index 00000000..ba9e73f2 --- /dev/null +++ b/specs/252-platform-localization-v1/data-model.md @@ -0,0 +1,65 @@ +# Data Model: Platform Localization v1 (DE/EN) + +## Supported Locale Set + +| Value | Meaning | Notes | +|---|---|---| +| `en` | English | System default and controlled fallback in v1 | +| `de` | German | First additional supported locale | + +## Locale Sources + +| Source | Ownership | Persistence | Allowed Values | Notes | +|---|---|---|---|---| +| `tenantpilot.locale_override` | request or session scoped | transient | `en`, `de` | Explicit temporary choice for the current browsing context | +| `users.preferred_locale` | user-owned | persisted on `users` | `en`, `de`, `null` | Personal preference; `null` means inherit | +| `localization.default_locale` | workspace-owned | existing workspace settings infrastructure | `en`, `de`, `null` | Workspace default for users without a personal preference | +| `config('app.locale')` | system-owned | config | `en` initially | Final fallback anchor | + +## Precedence Rule + +1. Explicit override +2. User preference +3. Workspace default +4. System default + +If the chosen source is missing, malformed, or unsupported, resolution falls back to the next valid source until a supported locale is found. The final controlled fallback is English. + +## Plane-Specific Resolution + +- **Admin and tenant panels**: use the full precedence rule above. +- **System panel**: uses `explicit override -> system default` only in v1 because system actors authenticate as `PlatformUser` and do not get a persisted locale preference or workspace-default inheritance in this slice. + +## Derived Resolved Locale Context + +| Field | Type | Meaning | +|---|---|---| +| `locale` | string | Effective locale for the current request (`en` or `de`) | +| `source` | string | One of `explicit_override`, `user_preference`, `workspace_default`, `system_default` | +| `fallback_locale` | string | Controlled fallback locale, `en` in v1 | +| `workspace_default_locale` | string or null | Current workspace default when a workspace context exists | +| `user_preference_locale` | string or null | Persisted personal locale preference for workspace-bound users; `null` on the system plane | + +## Persistence Shape + +- **User preference**: add one nullable locale preference field to the current workspace-bound user-owned surface. +- **Workspace default**: add one workspace setting definition under a localization-specific domain using the existing settings infrastructure. +- **No new table**: the first slice does not create a generic preferences or translation state table, and it does not add a second locale-preference store for `PlatformUser`. + +## Translation Catalog Ownership + +| Catalog Family | Ownership | Notes | +|---|---|---| +| `lang/en/*.php` | canonical English source | Existing `findings.php` and `baseline-compare.php` remain authoritative English catalogs | +| `lang/de/*.php` | German translation mirror | Added only for the selected first-wave surface families | +| generic shell or settings catalogs | platform runtime | Used for shell/auth/context-bar and shared operator text that does not belong to one domain file | + +## Invariance Boundaries + +The following stay non-localized in v1: + +- raw JSON and provider payloads +- audit entries and machine-readable audit values +- stored report payloads and exported artifact data +- identifiers, slugs, route parameters, and query semantics +- global-search scope, authorization outcomes, and tenant/workspace context selection diff --git a/specs/252-platform-localization-v1/plan.md b/specs/252-platform-localization-v1/plan.md new file mode 100644 index 00000000..e7844693 --- /dev/null +++ b/specs/252-platform-localization-v1/plan.md @@ -0,0 +1,287 @@ +# Implementation Plan: Platform Localization v1 (DE/EN) + +**Branch**: `252-platform-localization-v1` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Add one bounded locale foundation for the platform runtime only: admin and tenant panels use the full locale precedence chain (`explicit override -> user preference -> workspace default -> system default`), while the system panel uses the v1 subset (`explicit override -> system default`) because it authenticates a separate platform actor. +- Keep persistence narrow and repo-native: store the workspace default locale through the existing workspace settings infrastructure, persist the personal locale preference directly on the workspace-bound user surface, and avoid a generic preferences framework, a second settings stack, or a second preference store for `PlatformUser`. +- Translate the panel shell and the highest-signal governance surfaces first, including the shared context bar, auth copy, Findings, Baseline Compare, representative workspace and tenant membership tables, monitoring and operations feedback, and customer-safe review or report viewer chrome, while keeping exports, audit logs, JSON payloads, and other machine-readable artifacts invariant. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5 + Livewire v4, Laravel translator, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), current panel providers, existing Filament notifications and view layer +**Storage**: PostgreSQL via one workspace-bound user-owned locale preference field plus one workspace-owned locale default setting; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de` +**Testing**: Pest unit and feature tests via Laravel Sail +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Monorepo Laravel web application in `apps/platform` with admin, tenant, and system Filament panels +**Project Type**: web +**Performance Goals**: No extra remote calls during locale resolution, constant-time locale lookup from request/session + current user + workspace settings, and no measurable overhead on ordinary panel navigation or Livewire round-trips +**Constraints**: Exactly two locales (`en`, `de`), no `apps/website` scope, no new global-search semantics, no RBAC behavior change, invariant CSV/JSON/audit/raw payloads, and no generic preference framework +**Scale/Scope**: One locale resolver, one request-time locale application seam, one workspace default setting, one workspace-bound personal preference path, system-panel explicit-override support only, and first-wave translation coverage for shell/auth plus core governance surface families + +## Filament v5 / Panel Notes + +- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, render hooks, and Livewire-backed request flows. No Livewire v3 assumptions or compatibility work are introduced. +- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`. +- **Global search**: No new global-search resource is introduced and no global-search routing or authorization semantics are changed. Localization only affects visible copy where current search access already exists. +- **Destructive and high-impact actions**: No destructive action is added by this slice. Locale preference and workspace default changes are low-risk settings mutations; they still use existing authorization and settings audit paths where applicable. +- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed elsewhere in the product. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: mixed +- **Shared-family relevance**: shell navigation, auth copy, workspace settings, notifications, status messaging, dashboard and compare surfaces, customer-safe report viewers +- **State layers in scope**: shell, page, detail, URL-query or session +- **Audience modes in scope**: operator-MSP, support-platform, customer-read-only +- **Decision/diagnostic/raw hierarchy plan**: decision-first on shell and core governance surfaces; diagnostics-second on monitoring and operational feedback surfaces; support/raw payloads stay third and unchanged +- **Raw/support gating plan**: unchanged capability-gated or collapsed raw detail; localization applies only to surrounding UI copy, not to raw payloads or audit artifacts +- **One-primary-action / duplicate-truth control**: the shell remains the one place where language is chosen intentionally; all other surfaces consume the resolved locale and do not become independent configuration surfaces +- **Handling modes by drift class or surface**: review-mandatory because mixed-language drift across shell, notifications, and core governance surfaces would undercut the shared locale contract immediately +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: global-context-shell, standard-native-filament, shared-detail-family +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: no page-local locale state, no custom translation framework, no second shell, and no localized machine artifacts +- **Active feature PR close-out entry**: Guardrail + +## Review Outcome + +- **Outcome class**: acceptable-special-case +- **Workflow outcome**: keep +- **Why this remains acceptable**: the package touches multiple surface families, but every change is still anchored to one shared locale contract and a tightly bounded first-wave translation inventory. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `bootstrap/app.php`, panel providers (`AdminPanelProvider`, `TenantPanelProvider`, `SystemPanelProvider`), shared topbar render hook and `resources/views/filament/partials/context-bar.blade.php`, existing auth/login pages, workspace settings infrastructure, `User` model persistence, `PlatformUser`-backed system auth behavior, translation catalogs under `lang/`, Filament notifications, and representative governance/detail pages and report viewers +- **Shared abstractions reused**: existing translation helpers (`__()` and Laravel translator), existing settings registry/resolver/writer, current workspace context resolution, current panel render hooks, and existing Filament notification and page/resource surfaces +- **New abstraction introduced? why?**: one bounded `LocaleResolver` plus one request-time application seam are justified because the repo currently lacks any single locale precedence decision that can serve shell, auth, Livewire, notifications, and report viewers consistently +- **Why the existing abstraction was sufficient or insufficient**: Laravel translation helpers are already sufficient for rendering translated strings, and the workspace settings infrastructure is already sufficient for a workspace default on admin and tenant planes. They are insufficient because there is no central locale resolution contract and no workspace-bound user locale preference path today. +- **Bounded deviation / spread control**: no generic preferences registry, no page-local language switches, and no second translation catalog scheme beyond standard Laravel `lang/{locale}` files + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: localized copy only on existing run and monitoring surfaces +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: locale resolution, glossary translation, UI copy, and viewer chrome language behavior +- **Neutral platform terms / contracts preserved**: `Finding`, `Baseline`, `Drift`, `Risk Accepted`, `Evidence Gap`, `Run`, `Alert`, `Workspace`, `Tenant` +- **Retained provider-specific semantics and why**: none; provider payloads remain untranslated and raw where they already exist +- **Bounded extraction or follow-up path**: none + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS - the slice changes operator-facing runtime copy and locale choice only; it does not introduce new inventory or backup truth. +- Read/write separation: PASS - the only new writes are low-risk preference mutations using existing user/workspace ownership and current settings patterns. +- Graph contract path: PASS - no new Microsoft Graph path is introduced. +- Deterministic capabilities: PASS - authorization semantics for shell, workspace settings, and read-only viewers remain unchanged. +- RBAC-UX: PASS - `/admin`, `/admin/t`, and `/system` remain separated; language choice does not alter 404 versus 403 semantics. +- Workspace isolation: PASS - workspace selection and tenant selection stay authoritative, and locale does not create a second context layer. +- RBAC-UX destructive confirmation: N/A - no destructive action is introduced. +- RBAC-UX global search: PASS - search scope and visibility remain unchanged. +- Tenant isolation: PASS - translated labels and fallback text must not leak inaccessible tenant or workspace information. +- Run observability: PASS - no new run family or start flow is introduced. +- OperationRun start UX: N/A - no start semantics change. +- Ops-UX 3-surface feedback: PASS - only existing copy becomes locale-aware; lifecycle and notification mechanics stay unchanged. +- Ops-UX lifecycle: N/A - no lifecycle contract change. +- Ops-UX summary counts: N/A - no summary shape change. +- Ops-UX guards: N/A - no new run guard family is planned. +- Ops-UX system runs: N/A - unchanged. +- Automation: N/A - no new queued or scheduled workflow family is introduced. +- Data minimization: PASS - no new sensitive payload storage is introduced. +- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth. +- Proportionality (PROP-001): PASS - persistence stays to one workspace-bound user-owned preference and one workspace setting; one resolver is the narrowest viable shared seam. +- No premature abstraction (ABSTR-001): PASS - no registry, strategy system, or framework is planned beyond one locale resolver and supported-locale allowlist. +- Persisted truth (PERSIST-001): PASS - the new persisted values represent real workspace-bound user and workspace-owned preference truth, while the system plane remains explicit-override or system-default only. +- Behavioral state (STATE-001): PASS - the locale set changes real request behavior, formatting, and translated surface output. +- UI semantics (UI-SEM-001): PASS - the plan favors direct domain-to-translation mapping instead of a new interpretation framework. +- Shared pattern first (XCUT-001): PASS - existing translator, panel hooks, settings stack, and existing page/resource surfaces are reused first. +- Provider boundary (PROV-001): PASS - localization is platform-core and provider-neutral. +- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one resolver, one middleware path, two locales, and a bounded first-wave surface inventory. +- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the whole locale foundation remains in one coherent spec and explicitly avoids website/email/framework drift. +- Badge semantics (BADGE-001): PASS - translated badges continue to use existing central semantics rather than new color or state mappings. +- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages/resources/widgets, login pages, and render-hook partials. +- Filament-native UI local Blade/Tailwind: PASS - existing custom Blade surfaces like the shared context bar and selected viewer shells remain in Filament visual language. +- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - no new surface type is introduced. +- Decision-first operating model (DECIDE-001): PASS - shell choice happens once, and primary governance surfaces stay decision-first. +- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - localization improves readability without exposing hidden diagnostics or translating raw payloads. +- UI/UX inspect model (UI-HARD-001): PASS - no inspect/open model changes are planned. +- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - existing action hierarchy remains intact. +- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - canonical nouns remain stable across translated shells and pages. +- UI/UX placeholder ban (UI-HARD-001): PASS - no placeholder controls are planned. +- UI naming (UI-NAMING-001): PASS - translated labels preserve `Verb + Object` semantics and canonical domain vocabulary. +- Operator surfaces (OPSURF-001): PASS - shell, governance, monitoring, and customer-safe viewers stay explicit and bounded. +- Operator surface page contract: PASS - the spec already defines the affected surface contracts. +- Filament UI Action Surface Contract: PASS - no new action family is introduced beyond one shell-level locale control and a workspace settings field. +- Filament UI UX-001 (Layout & IA): PASS - the slice extends existing shells, settings, and detail surfaces only. +- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - language choice stays on the shell or settings surfaces and is not duplicated on every page. +- UI review workflow: PASS - guardrail classification, shell ownership, fallback behavior, and invariant machine-format rules remain explicit in this plan. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for locale precedence and validation; `Feature` for request-time application, workspace and personal preference flows, translated core surfaces, localized feedback, and invariant machine-format behavior +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: the core risk is deterministic resolution and rendered surface behavior across existing request paths, not browser-only interaction nuance or heavy governance semantics +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php` +- **Fixture / helper / factory / seed / context cost risks**: limited to users, workspaces, memberships, workspace settings, session override state, and representative governance surface fixtures; add one focused wrong-plane or non-member and missing-capability proof path without widening the test family +- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and add only thin locale helpers where repeated assertions demand it +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: global-context-shell proof for request-wide locale behavior, standard-native relief for ordinary Filament surfaces, and shared-detail-family proof for localized report viewer chrome with invariant artifacts +- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify admin login, tenant panel, and system panel locale continuity, verify unsupported locale fallback behavior, verify dashboard plus core governance surfaces do not render raw keys, verify wrong-plane or non-member 404 and member-but-no-capability 403 behavior stays unchanged under locale changes, and verify exported or audited machine formats remain stable with no new remote locale lookups introduced +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth +- **Review-stop questions**: does one resolver truly own locale precedence, does Livewire preserve the selected locale, does the first-wave translation scope stay bounded, and do exports or audit payloads remain invariant +- **Escalation path**: document-in-feature if one surface family needs temporary English-only fallback; follow-up-spec only if a later broader email or website localization program becomes necessary +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the planned work stays bounded to the platform runtime and current high-signal governance surfaces; broader public-site or multi-locale expansion remains explicitly out of scope + +## Project Structure + +### Documentation (this feature) + +```text +specs/252-platform-localization-v1/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── locale-resolution-and-translation-governance.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Http/ +│ │ └── Middleware/ +│ │ └── ApplyResolvedLocale.php +│ ├── Models/ +│ │ └── User.php +│ ├── Providers/ +│ │ ├── AppServiceProvider.php +│ │ └── Filament/ +│ │ ├── AdminPanelProvider.php +│ │ ├── TenantPanelProvider.php +│ │ └── SystemPanelProvider.php +│ ├── Services/ +│ │ ├── Localization/ +│ │ │ └── LocaleResolver.php +│ │ └── Settings/ +│ │ ├── SettingsResolver.php +│ │ └── SettingsWriter.php +│ └── Support/ +│ └── Settings/ +│ └── SettingsRegistry.php +├── bootstrap/ +│ └── app.php +├── database/ +│ └── migrations/ +│ └── *_add_preferred_locale_to_users_table.php +├── lang/ +│ ├── de/ +│ └── en/ +├── resources/views/ +│ └── filament/ +│ ├── partials/context-bar.blade.php +│ ├── pages/ +│ ├── widgets/ +│ └── system/ +└── tests/ + ├── Feature/ + │ ├── Filament/Localization/ + │ └── Localization/ + └── Unit/ + └── Localization/ +``` + +**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded localization resolver, one request-time locale application path, one workspace-bound user preference mutation path, one workspace-owned default setting, system-panel explicit-override support only, and focused translation catalog growth for selected existing surfaces. + +## Likely Implementation Surfaces + +- `bootstrap/app.php` plus a new `app/Http/Middleware/ApplyResolvedLocale.php` for request-time locale application in ordinary web requests and panel traffic +- `app/Providers/Filament/AdminPanelProvider.php`, `TenantPanelProvider.php`, and `SystemPanelProvider.php` for consistent panel-level middleware and shell affordances +- `resources/views/filament/partials/context-bar.blade.php` as the shared topbar language-control anchor for the admin and tenant panels +- current auth pages such as `app/Filament/Pages/Auth/Login.php` and `app/Filament/System/Pages/Auth/Login.php` for translated login and auth-adjacent copy +- `app/Models/User.php` plus a user migration for the personal locale preference field, while `PlatformUser` remains on explicit override plus system default only +- `app/Support/Settings/SettingsRegistry.php`, `app/Services/Settings/SettingsResolver.php`, `app/Services/Settings/SettingsWriter.php`, and `app/Filament/Pages/Settings/WorkspaceSettings.php` for the workspace-owned default locale path and audit-backed save semantics +- a new `app/Services/Localization/LocaleResolver.php` for precedence, supported-locale validation, and fallback behavior +- `lang/en/*` and new `lang/de/*` catalogs for shell, Findings, Baseline Compare, monitoring, operations, workspace or tenant management tables, and customer-safe review or report viewer shells +- representative existing surfaces such as `app/Filament/Pages/TenantDashboard.php`, `app/Filament/System/Pages/Dashboard.php`, `app/Filament/Resources/FindingResource.php`, `app/Filament/Pages/BaselineCompareLanding.php`, `resources/views/filament/pages/baseline-compare-landing.blade.php`, `resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php`, `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, and `app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php` +- localized feedback surfaces such as current Filament notifications, validation messages, and relative-time labels already present across monitoring, onboarding, and review surfaces + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One bounded `LocaleResolver` | Shell, auth, Livewire, notifications, and report viewers need one deterministic locale source | Page-local or panel-local locale reads would drift immediately and make fallback behavior inconsistent | +| One new workspace locale setting plus one personal preference field | The roadmap precedence chain requires real persisted workspace and user truth | Session-only locale switching would not satisfy inherited defaults or stable user choice | + +## Proportionality Review + +- **Current operator problem**: The product is partially translation-aware but not intentionally localized. Operators cannot choose a language reliably, and current core surfaces mix raw English with extracted translations. +- **Existing structure is insufficient because**: Laravel translation helpers alone do not answer which locale to use, when to inherit workspace defaults, how to persist a user choice, or how to keep Livewire and report-viewer surfaces aligned. +- **Narrowest correct implementation**: exactly two locales, one locale resolver where admin/tenant panels use the full precedence chain and the system panel uses explicit override plus system default, one workspace-bound user preference field, one workspace setting, one request-time application path, and a bounded first-wave translation inventory on existing high-signal surfaces. +- **Ownership cost created**: ongoing EN/DE catalog maintenance, one resolver, one migration, one workspace setting key, and regression tests for fallback plus invariant machine formats. +- **Alternative intentionally rejected**: a generic preferences framework, broad website/email program, or translating every page first was rejected because the current release needs a runtime foundation, not a full localization platform. +- **Release truth**: current-release truth. Core governance, monitoring, and customer-safe review surfaces already need language continuity in the live platform. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md` + +Goals: +- Confirm the narrowest persistence shape for user preference plus workspace default without creating a generic preferences subsystem. +- Confirm the cleanest request-time locale application seam across normal web and Livewire requests for all three current panels, while keeping the system panel on explicit override plus system default only. +- Confirm which first-wave governance and viewer surfaces are already translation-aware enough to translate now and which ones still rely on raw English strings. +- Confirm invariant machine-format boundaries for exports, audit entries, report payloads, and raw evidence. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` + +Design focus: +- Persist one personal locale preference directly on the workspace-bound user-owned surface and one workspace default locale through the existing settings infrastructure. +- Add one bounded locale resolver plus one request-time middleware or application path shared by admin, tenant, and system panels, with explicit override plus system default only on the system plane. +- Place the user-facing locale switch on the existing shared shell or context surface instead of inventing a new page shell. +- Translate first-wave shell, governance, monitoring, and customer-safe viewer surfaces using standard Laravel catalogs and controlled English fallback. +- Keep exports, audit logs, raw JSON, and machine-readable artifacts invariant even when the surrounding UI becomes locale-aware. + +## Implementation Close-Out + +- **Workflow outcome**: keep. +- **Validation lanes completed**: fast-feedback and confidence. +- **Targeted proof results**: + - `./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php ... tests/Feature/Localization/MachineFormatInvarianceTest.php` passed with 15 tests and 103 assertions. + - `./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php` passed with 18 tests and 248 assertions. + - `./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php` passed with 24 tests and 135 assertions. + - `./vendor/bin/sail bin pint --dirty --format agent` passed. + - `git diff --check` passed. +- **Browser smoke result**: passed on `http://localhost/admin/settings/workspace`, `http://localhost/admin/t/18000000-0000-4000-8000-000000000180`, and `http://localhost/admin/reviews/workspace`. The smoke verified the shared language switch from English to German, German locale menu state, tenant dashboard German navigation/title, customer review workspace German viewer chrome, no raw `localization.*` keys, and no current browser console errors from the tested tab. +- **Guardrail close-out**: acceptable-special-case / keep remains valid because the implementation still uses one resolver, one middleware seam, one user preference field, one workspace setting key, standard Laravel catalogs, and no localized machine artifact path. +- **document-in-feature note**: broader pre-existing Workspace Settings sections and deeper diagnostic/payload text outside the locale setting and review/report chrome may still render English in German mode. This is recorded as existing unrelated localization debt rather than widened into this first platform-runtime slice; the active implementation localizes the new locale controls, workspace default locale field, core shell/dashboard labels, Findings/Baseline catalog coverage, notifications, and customer-safe review/report chrome. diff --git a/specs/252-platform-localization-v1/quickstart.md b/specs/252-platform-localization-v1/quickstart.md new file mode 100644 index 00000000..2b2995f2 --- /dev/null +++ b/specs/252-platform-localization-v1/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Platform Localization v1 (DE/EN) + +## Goal + +Implement one deterministic locale foundation for the platform runtime, then translate the first-wave shell and governance surfaces without changing authorization or machine-readable artifact truth. + +## Targeted Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Smoke Focus + +1. Open the admin login page and a representative system-panel page, then verify locale-specific auth and system copy using explicit override plus system default only. +2. Set workspace default locale to `de` on the existing workspace settings surface, verify an inheriting user sees German admin shell, tenant shell, and tenant dashboard copy, then clear the workspace default and verify inheritance falls back to the system default. +3. Set a personal locale preference to `en` and verify the shell, dashboards, and representative governance pages switch back to English. +4. Apply and clear the explicit temporary override and verify it wins only while active. +5. Open representative Findings, Baseline Compare, and representative workspace or tenant management tables, then confirm headings, actions, empty states, and glossary terms follow the resolved locale. +6. Open representative monitoring, alert, and operations surfaces and confirm labels, notifications, and relative-time text follow the resolved locale without changing workflow semantics. +7. Open a customer-safe review or report viewer and confirm the viewer shell localizes while underlying artifact content and identifiers stay unchanged. +8. Trigger a representative validation error and a representative notification and confirm they render in the resolved locale. +9. Verify wrong-plane or non-member requests still resolve as 404 and member-but-no-capability requests still resolve as 403 after locale changes or overrides. +10. Verify exported or audited machine-readable values stay stable and non-localized. + +## Reviewer Watchpoints + +- One resolver owns locale precedence. +- The system panel is explicit-override plus system-default only in v1; it does not silently inherit workspace default or a second persisted preference model. +- Livewire requests preserve the already-resolved locale. +- Unsupported locale input falls back safely to English. +- Locale changes do not alter wrong-plane 404, non-member 404, member-but-no-capability 403, or global-search visibility. +- First-wave translation coverage stays bounded to the planned surface families. +- No raw translation keys appear on in-scope surfaces. +- Exports, audit entries, raw payloads, and IDs remain invariant. +- Locale resolution stays local to request, session, user, and workspace settings inputs with no extra remote lookups. diff --git a/specs/252-platform-localization-v1/research.md b/specs/252-platform-localization-v1/research.md new file mode 100644 index 00000000..715baa25 --- /dev/null +++ b/specs/252-platform-localization-v1/research.md @@ -0,0 +1,51 @@ +# Research: Platform Localization v1 (DE/EN) + +## Decision 1: Keep v1 to exactly two locales + +- **Decision**: Support exactly `en` and `de` in the initial slice. +- **Why**: The roadmap names DE/EN explicitly, the repo already defaults to English, and the main risk is establishing a trustworthy locale chain and translation ownership, not proving a broad language framework. +- **Rejected alternative**: A generic multi-locale system or plugin registry was rejected because it would import framework-level complexity before the first runtime locale foundation exists. + +## Decision 2: Persist one user preference plus one workspace default + +- **Decision**: Store the personal locale preference directly on the workspace-bound user-owned surface and store the workspace default locale through the existing workspace settings infrastructure. +- **Why**: The repo already has a strong workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`) but no generic user settings registry. A direct workspace-bound user preference field is the narrowest truthful shape. +- **Rejected alternative**: A new generic preferences table or user-settings registry was rejected because the first release needs only one personal locale field. + +## Decision 3: Resolve locale in one shared request-time seam + +- **Decision**: Add one `LocaleResolver` and one request-time application path that can run for normal web requests and Livewire requests across admin, tenant, and system panels. +- **Why**: Repo evidence shows three active panel providers, shared topbar partials, and existing Livewire-specific middleware. Locale must stay coherent across those paths. +- **Rejected alternative**: Per-panel or per-page locale logic was rejected because it would drift immediately and would not solve mixed-language notifications or viewer shells. + +## Decision 4: Keep system-panel locale scope narrower than admin or tenant scope + +- **Decision**: Admin and tenant panels use the full precedence chain, but the system panel uses `explicit override -> system default` only in v1. +- **Why**: Repo truth shows the system panel authenticates `PlatformUser`, which is a separate actor model from workspace-bound `User`. Adding a second persisted preference store for platform actors would widen the slice beyond the narrow runtime localization foundation. +- **Rejected alternative**: A new platform-user locale preference or implicit inheritance from workspace default was rejected because the system plane is not workspace-owned and should not silently reuse workspace preference semantics. + +## Decision 5: Reuse current shell and settings surfaces instead of inventing new UI + +- **Decision**: Use the shared context bar or existing shell-adjacent controls for the user-facing locale switch, and use the existing workspace settings page for the workspace default locale. +- **Why**: The repo already has a shared render-hook surface in `resources/views/filament/partials/context-bar.blade.php` and an existing `WorkspaceSettings` page. That is the narrowest native Filament path. +- **Rejected alternative**: A dedicated localization page or a second profile/settings shell was rejected because it would duplicate shell-level context choice. + +## Decision 6: Translate first-wave high-signal surfaces only + +- **Decision**: First-wave translation coverage includes shell/auth, the current dashboards, Findings, Baseline Compare, representative workspace and tenant management tables, monitoring or operational feedback labels, and customer-safe review or report viewer chrome. +- **Why**: Repo evidence already shows translation-related usage on Findings and Baseline Compare, while the shared context bar, dashboards, and several relationship tables still contain many raw English strings. These surfaces represent the most visible operator and customer-safe workflows. +- **Rejected alternative**: Translating every current page in one slice was rejected because it would broaden scope faster than the locale foundation can be validated. + +## Decision 7: Keep machine-readable artifacts invariant + +- **Decision**: Locale affects UI copy, validation text, and date or relative-time formatting on in-scope surfaces, but not raw JSON, CSV, audit entries, IDs, or stored machine-readable report artifacts. +- **Why**: The roadmap requires stable export and audit semantics, and the product already uses customer-safe viewers and operational evidence where raw truth must remain stable. +- **Rejected alternative**: Localizing stored or exported artifacts was rejected because it would blur audit truth and increase downstream compatibility cost. + +## Repo Evidence Snapshot + +- `config/app.php` currently uses English as both default and fallback locale. +- The repo currently has only two explicit language catalogs: `lang/en/findings.php` and `lang/en/baseline-compare.php`. +- Translation helpers (`__()`) are already used across multiple Filament resources, notifications, and Blade views, but many shell and management strings remain raw English. +- The shared context bar partial is a concrete shell anchor and currently contains multiple hard-coded English labels. +- The repo has no current locale resolver, no workspace locale setting, and no personal locale preference field on `User`. diff --git a/specs/252-platform-localization-v1/spec.md b/specs/252-platform-localization-v1/spec.md new file mode 100644 index 00000000..3e1e32a1 --- /dev/null +++ b/specs/252-platform-localization-v1/spec.md @@ -0,0 +1,319 @@ +# Feature Specification: Platform Localization v1 (DE/EN) + +**Feature Branch**: `252-platform-localization-v1` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Prepare the Spec Kit feature for Localization v1 as a narrow repo-grounded slice that introduces locale resolution and EN/DE translation coverage on core governance surfaces, reuses existing translation helpers and current admin/system panels, and stops before website localization or full documentation translation." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot already contains scattered translation-aware code such as `__()` calls and two domain language files, but it still lacks one central locale resolution path, one supported locale policy, and one bounded definition of which operator-facing surfaces are translated first. +- **Today's failure**: Users cannot intentionally switch the platform language, many core surfaces still mix extracted translations with raw English strings, relative-time labels stay English-only, and customer-safe review/report flows cannot reliably align to the reader's language without risking raw keys or inconsistent terminology. +- **User-visible improvement**: Operators can use the product in English or German, new users inherit a workspace default language unless they set their own preference, core governance surfaces render consistent translated copy and locale-aware time labels, and exports, audit records, and machine-readable artifacts remain stable and non-localized. +- **Smallest enterprise-capable version**: Add one request-time locale foundation where admin and tenant panels use `explicit override -> user preference -> workspace default -> system default`, the system panel uses `explicit override -> system default`, support exactly `en` and `de`, persist only the workspace default plus personal user preference for workspace-bound users, translate the panel shell plus the highest-signal governance surfaces first, and enforce controlled English fallback with no raw translation keys in the UI. +- **Explicit non-goals**: No `apps/website` localization, no arbitrary locale/plugin system, no public docs translation pipeline, no CSV/JSON/audit artifact localization, no provider/API payload translation, no full outbound email/template program, and no search/sort behavior rewrite beyond verifying locale safety on the current core lists. +- **Permanent complexity imported**: One bounded locale precedence chain, one supported-locale allowlist, one workspace-owned default locale setting, one workspace-bound user-owned locale preference, additional `lang/en` and `lang/de` catalogs for the selected core surfaces, and focused regression tests for fallback, formatting, and invariant machine-readable outputs. +- **Why now**: `R1.9 Platform Localization v1 (DE/EN)` is explicitly unspecced in the roadmap, the repo already has partial translation scaffolding (`lang/en/findings.php`, `lang/en/baseline-compare.php`, many `__()` calls), and read-only/customer-safe review maturity now needs a trustworthy locale foundation before more outward-facing product work lands. +- **Why not local**: Locale choice must affect the same request across Filament shells, auth, Livewire requests, notifications, relative-time rendering, and core governance pages. Translating page by page without a shared resolver contract would hard-code inconsistent language sources immediately. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Foundation-sounding theme, cross-surface touchpoint, and one new shared resolver. Defense: the slice is strictly limited to two locales, one precedence chain, one workspace default, one personal preference, and a bounded first-wave translation set on already-real surfaces. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Review Outcome + +- **Outcome class**: acceptable-special-case +- **Workflow outcome**: keep +- **Reason**: The slice is intentionally broad across visible runtime surfaces, but it stays bounded to one shared locale foundation, two supported locales, one user preference path, one workspace default path, and first-wave translation coverage only. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - current `/admin` and `/system` panel shells, including auth entry surfaces such as `/admin/login` + - the existing workspace settings surface for a workspace-owned default locale + - the current self-service user locale preference entry point in the panel shell or profile/user-menu area + - existing high-signal governance surfaces under `/admin`, including dashboard, Findings, Baseline Compare, Alerts/Monitoring, Operations, and customer-safe review/report consumption surfaces +- **Data Ownership**: Personal locale preference is user-owned truth for the workspace-bound `User` actor and should persist on that user surface only. Workspace default locale is workspace-owned truth and should reuse the existing workspace settings infrastructure for admin/tenant-plane inheritance only. Explicit override is transient request/session state. System-panel `PlatformUser` actors do not get a separate persisted locale preference in v1. Exports, audit logs, stored report content, raw JSON, and machine-readable identifiers remain unchanged and non-localized. +- **RBAC**: Authenticated workspace-bound users may set or clear their own personal locale preference and temporary explicit override on admin/tenant surfaces. Workspace owners/managers may change the workspace default locale on the existing workspace settings surface. System-panel actors may use the explicit override only in v1 and otherwise inherit the system default. Existing workspace and tenant membership checks remain authoritative. Wrong-plane and non-member access stays 404, and missing capability on workspace settings stays 403. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Locale changes MUST NOT alter existing tenant-context defaults, current filters, query ownership, search scoping, or canonical list routing. The current tenant/workspace context remains authoritative and language selection only affects presentation. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. Locale selection MUST NOT reveal inaccessible tenants, operations, findings, or global-search hints through translated labels, fallback strings, or locale-specific navigation branches. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation, auth copy, status messaging, action labels, notifications, validation/system texts, dashboard signals, evidence/report viewers, relative-time and date labels +- **Systems touched**: Laravel translator, current `lang/*` catalogs, Filament panel providers, existing auth page copy, workspace settings infrastructure, workspace-bound user model preference storage, system-panel actor handling, Livewire request handling, Filament notifications, and core governance/detail Blade and resource surfaces +- **Existing pattern(s) to extend**: current `__()` usage, existing domain language files, Filament vendor translation layer, existing workspace settings stack, and the current user/session context path +- **Shared contract / presenter / builder / renderer to reuse**: existing translation helpers and settings infrastructure remain canonical; this slice adds one bounded locale resolver and one supported-locale allowlist rather than a second presentation framework +- **Why the existing shared path is sufficient or insufficient**: The current translator and extracted keys are sufficient for rendering translated copy, but they are insufficient because the repo has no single locale resolution contract, no workspace default locale, no workspace-bound user preference path, and no guard against mixed raw English plus translated key usage on the same surfaces. +- **Allowed deviation and why**: none. No surface may invent a local locale source, inline hard-coded German strings, or page-local fallback behavior. +- **Consistency impact**: Canonical glossary terms such as Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Alert, and Run must stay semantically aligned across shell navigation, dashboard tiles, findings/detail views, compare surfaces, notifications, and customer-safe report viewers. +- **Review focus**: Reviewers must verify one shared locale resolver contract, the narrower system-panel source set, no mixed-language operator surface after translation extraction, no raw keys in the rendered UI, and unchanged tenant/workspace authorization semantics. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +N/A - no `OperationRun` start, completion, dedupe, or link semantics are changed by this slice. Existing run-related copy becomes locale-aware, but the run lifecycle contract remains unchanged. + +## 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`)* + +N/A - no shared provider/platform boundary is changed. Localization must translate platform vocabulary without importing provider-specific aliases into platform-core truth. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | yes | Native Filament panels plus existing auth page | navigation, user-menu/profile actions, auth/system texts | shell, detail, URL-query/session | no | No new panel or shell type is introduced | +| Core governance surfaces | yes | Mixed native Filament resources/pages plus existing Blade views | status messaging, table labels, empty states, glossary terms | page, detail | no | First wave only: dashboard, Findings, Baseline Compare, high-signal tenant/workspace management tables | +| Monitoring and operational feedback surfaces | yes | Mixed native Filament pages/widgets and shared presenters | notifications, alerts, run/status labels, relative time | page, detail | no | Start/completion semantics stay unchanged; only copy/formatting is localized | +| Customer-safe review/report consumption surfaces | yes | Native Filament detail/report viewers | report titles, helper text, read-only evidence/report language | page, detail | no | Machine-readable report payloads and downloads remain invariant | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | Primary Decision Surface | User decides which language the product should use for the current session or their persisted preference | Current language, available language choices, and whether workspace default applies | Workspace-default explanation and reset-to-default detail | Primary because this is the only place where language is intentionally changed | Keeps language choice inside the shell instead of hidden product-by-product | Removes manual browser-translation workarounds and support explanations | +| Core governance surfaces | Primary Decision Surface | Operator interprets findings, compare results, and tenant/workspace state | Localized headings, state text, actions, empty states, and glossary-consistent labels | Existing raw evidence and detailed diagnostics remain secondary | Primary because these are the actual governance decision surfaces that must be readable first | Preserves the current governance workflow while making the first read understandable in the chosen language | Reduces operator reconstruction of English-only labels and mixed terminology | +| Monitoring and operational feedback surfaces | Secondary Context Surface | Operator interprets toasts, alerts, validation errors, and relative-time context while working | Localized notification titles/bodies, validation/system copy, and relative-time labels | Existing run detail, technical diagnostics, and raw payloads remain secondary | Not primary because these surfaces support ongoing work rather than replace the main decision pages | Keeps supporting feedback aligned with the main locale choice | Avoids context switching between translated pages and English-only feedback | +| Customer-safe review/report consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer or operator reads existing review/report content | Localized viewer shell, labels, and helper text with invariant machine data | Raw evidence and exported artifacts remain non-localized or separately scoped | Not primary because these surfaces answer evidence questions rather than control product configuration | Preserves read-only review/report flows without creating a separate localization program | Avoids mixed-language read-only experiences during customer handoff | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | operator-MSP, support-platform | Current language, override source, and clear change/reset actions | Workspace-default source and personal-preference explanation | No raw settings or session payload by default | `Switch language` or `Use workspace default` | Session mechanics and internal storage details stay hidden | The surface states one resolved language and one source instead of listing multiple conflicting candidates | +| Core governance surfaces | operator-MSP, support-platform | Localized titles, statuses, actions, and empty-state guidance | Existing diagnostics and technical detail stay secondary | Raw JSON/provider payloads remain hidden or capability-gated as today | Existing primary governance action remains primary | Translation-key details and raw glossary mappings stay hidden | Canonical glossary terms are translated once and reused across related surfaces | +| Monitoring and operational feedback surfaces | operator-MSP, support-platform | Localized toasts, validation errors, and relative time/context labels | Existing technical diagnostics on run/detail pages | Raw support payloads remain collapsed or gated | Existing remediation or navigation action remains primary | Internal fallback markers and untranslated key debugging stay hidden | The same message family is localized centrally rather than page-local per toast | +| Customer-safe review/report consumption surfaces | customer-read-only, operator-MSP | Localized shell copy and helper labels around existing review/report content | Existing provenance and review history remain secondary | Machine payloads and exported artifact data stay stable and non-localized | Existing `View` or `Download` action remains primary | Support/raw detail remains gated or omitted | Viewer shell text localizes without creating a second localized export format | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | Global context shell | Shell + preference control | Change the current language or reset to inherited default | Shell affordance plus existing settings/profile surface | forbidden | Existing user-menu or settings placement remains secondary to main navigation | none | `/admin` and `/system` shells | existing user/profile or workspace settings surface | Current panel, workspace context, and current locale source | Language / Locale | Current language and override source | Acceptable shell exception because locale is a true global context choice | +| Core governance surfaces | List / Detail / Dashboard | Native resource/page family | Continue the current governance action using translated labels | Existing row click, detail pages, and dashboard cards stay unchanged | existing | Existing More/detail header actions remain where they already live | unchanged | existing governance collection routes under `/admin` | existing governance detail routes under `/admin` | Active workspace and tenant context remain unchanged | Findings, Baselines, Alerts, Runs | Localized decision copy and canonical glossary terms | No new surface type introduced | +| Monitoring and operational feedback surfaces | Monitoring / Status / Feedback | Widget/page plus transient notification families | Act on a toast, alert, validation error, or monitoring label | Existing page/widget affordances stay unchanged | existing where already supported | Existing secondary navigation remains secondary | unchanged | existing monitoring and operations routes | existing run/alert detail routes | Active workspace, run, or alert context remains unchanged | Alerts / Operations / Notifications | Localized feedback copy and relative time labels | Feedback-only translation; no action hierarchy change | +| Customer-safe review/report consumption surfaces | Detail / Report viewer / Download | Read-only viewer family | Read or download the current review/report content | Existing read-only view/download surfaces | existing where already supported | Existing navigation remains secondary | none | existing review/report collections | existing review/report detail routes | Active workspace and tenant context remain unchanged | Review / Report / Evidence | Localized shell labels around stable artifact truth | No new localized export format is introduced | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | Any authenticated operator | Decide which language the product should render now | Global shell and settings detail | Which language should this product view use? | Current language, current source, available choices | Internal source precedence and fallback explanation | locale source, current locale | TenantPilot only | Switch language, clear override, save preference, save workspace default | none | +| Core governance surfaces | Workspace operator or platform reviewer | Interpret governance state and continue the current action in the chosen language | List/detail/dashboard family | What needs action right now, in a language I can reliably read? | Localized titles, labels, statuses, empty-state guidance, and action labels | Existing raw evidence and diagnostics | governance state, lifecycle/readiness, data completeness | none beyond existing actions | Existing primary governance actions | existing dangerous actions remain unchanged | +| Monitoring and operational feedback surfaces | Workspace operator or support operator | Understand transient system feedback and supporting context | Feedback/status family | What did the system just tell me, and what should I do next? | Localized notifications, validation messages, and relative-time context | Existing run/alert diagnostic detail | message intent, run status, alert state | none beyond existing actions | Existing follow-up navigation or retry actions | existing dangerous actions remain unchanged | +| Customer-safe review/report consumption surfaces | Customer-safe reader or workspace operator | Read review/report content with translated viewer chrome | Read-only viewer family | What does this review/report say in my chosen language without changing the underlying evidence? | Localized headings, section labels, helper text, and stable dates/times formatting | Existing provenance and support-only detail | report state, artifact availability | none | Existing view/download actions | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes - one resolved locale chain becomes current-release presentation truth, but it derives from existing config plus one user preference and one workspace default rather than a new generic preferences framework +- **New persisted entity/table/artifact?**: yes - one personal locale preference field and one workspace locale default setting +- **New abstraction?**: yes - one bounded locale resolver and one request-time application seam +- **New enum/state/reason family?**: yes - the supported locale set is explicitly bounded to `en` and `de` +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators and customer-safe readers currently encounter a partially translated product with no reliable way to select or inherit a language across shell, actions, notifications, and core governance views. +- **Existing structure is insufficient because**: Existing `__()` usage and two language files provide raw translation capability, but there is no central locale source, no persisted workspace default, no personal override, and no governed first-wave translation scope. +- **Narrowest correct implementation**: Keep the locale set at two languages, add one resolver where admin/tenant panels use the full precedence chain and the system panel uses explicit override plus system default, persist only one workspace-bound user preference and one workspace default, translate the highest-signal operator/report surfaces first, and leave exports/audit/machine artifacts untouched. +- **Ownership cost**: Ongoing maintenance of EN/DE catalogs, translation-key governance for the selected surface families, and regression tests for fallback and invariant machine outputs. +- **Alternative intentionally rejected**: A generic user-settings registry, multi-locale plugin system, or page-by-page translation without a resolver was rejected because the current release only needs one trustworthy locale chain and a bounded first-wave translation set. +- **Release truth**: current-release truth. The platform already has outward-facing review/report and governance workflows that need a consistent locale foundation now. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage proves locale precedence, supported-locale validation, fallback behavior, and invariant machine-format decisions. Focused feature coverage proves request-time application across Filament and auth surfaces, workspace/user preference flows, translated core surfaces, localized notifications/validation, and unchanged export/audit semantics without requiring browser or heavy-governance lanes. +- **New or expanded test families**: one bounded locale resolver unit family plus focused localization feature coverage for preferences, core governance surfaces, notifications/validation, and fallback/invariant behavior +- **Fixture / helper cost impact**: low. Add only user, workspace, workspace membership, workspace setting, session override, and representative governance surface fixtures. Avoid browser harness growth and avoid a generic translation-seeding framework. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell, standard-native-filament, shared-detail-family +- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for panel shell and settings surfaces. Global-context-shell coverage is required for locale precedence and request/application flow. Shared-detail-family coverage is required for localized review/report viewer chrome without altering machine-readable content. +- **Reviewer handoff**: Reviewers must confirm the precedence chain is deterministic, unsupported locales fail safely to English, Livewire requests preserve the resolved locale, critical governance surfaces stop mixing English raw strings with translated keys, relative times and validation messages localize correctly, and CSV/JSON/audit artifacts stay stable. +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php` + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- One shared locale resolver contract where admin and tenant panels use `explicit override -> user preference -> workspace default -> system default` and the system panel uses `explicit override -> system default` +- Exactly two supported locales in v1: `en` and `de` +- Workspace-owned default locale using the existing workspace settings infrastructure +- User-owned personal locale preference and a clear reset-to-inherited behavior +- Request-time locale application across current Filament admin, tenant, and system/auth flows, including Livewire requests, with the system panel limited to explicit override plus system default +- First-wave translation coverage for panel shell/navigation/auth plus the highest-signal governance surfaces already present in the repo +- Localized notifications, validation/system messages, and relative-time/date/number formatting for those in-scope surfaces +- Controlled English fallback with no raw translation keys rendered in the UI +- Explicit preservation of invariant exports, audit logs, JSON payloads, IDs, and machine-readable report artifacts + +### Non-Goals + +- `apps/website` localization or public documentation translation +- More than two locales in v1 +- A generic user preferences framework or multi-tenant localization framework +- Provider/API payload translation or localized stored evidence/report artifacts +- Full outbound email template localization beyond the minimum auth/system texts already on the current panel flow +- Search ranking, sorting rules, or global-search behavior changes beyond verifying locale safety +- Pseudolocalization as a full product lane; at most one bounded smoke/guard check may support the initial implementation +- A separate persisted locale-preference store for `PlatformUser` system actors + +## Assumptions + +- `config('app.locale')` and `config('app.fallback_locale')` remain the system-default and fallback anchors, both currently English. +- English remains the controlled fallback whenever a German translation key is missing. +- Workspace default locale is a presentation preference only. It does not change authorization, tenant/workspace scope, or machine-readable data. +- System-panel actors in v1 use explicit override plus system default only; they do not inherit workspace default or a second persisted preference store. +- Relative time, date, and number formatting should follow the resolved locale on operator-facing surfaces, but stored timestamps, raw payloads, exports, and audit values remain unchanged. +- The first-wave translation scope is bounded to shell/auth/settings plus existing high-signal governance pages already present in the repo, not every product page. + +## Risks + +- Mixed inline `__('Raw English')` usage and keyed translation files can leave surfaces partially translated if extraction rules are not explicit. +- Livewire and panel-specific middleware may drift if locale application is only added to one request path. +- Localizing viewer chrome while keeping exports/audit invariant can be confused if teams try to translate machine-readable payloads later. +- The glossary can drift between `findings`, `baseline-compare`, and generic panel labels if key ownership is not explicit. + +## Deferred Adjacent Candidates + +- Full public website localization remains a separate website-track concern. +- Broad email/template localization, knowledge-base localization, and public documentation translation stay deferred until the operator-facing runtime foundation is stable. +- Additional locales beyond German and English stay deferred until the two-locale workflow, fallback behavior, and glossary governance are proven. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Resolve and persist one trustworthy locale per request (Priority: P1) + +As an authenticated operator, I want the platform to resolve one language deterministically for the current request so I can work in my chosen language without manually reinterpreting each page. + +**Why this priority**: Without one shared locale chain, every later translation task remains fragile and page-local. + +**Independent Test**: Set a workspace default locale, optionally set a personal locale preference, optionally trigger an explicit override, and verify that admin/tenant requests use the full chain while system-panel requests use explicit override plus system default on both normal and Livewire-backed panel pages. + +**Acceptance Scenarios**: + +1. **Given** a user has no personal locale preference and no explicit override, **When** they open the panel inside a workspace with default locale `de`, **Then** the request resolves to `de` and the shell renders German copy. +2. **Given** the same workspace default is `de` but the user has personal locale preference `en`, **When** they open the panel, **Then** the request resolves to `en`. +3. **Given** the user currently resolves to `en`, **When** they set an explicit temporary override to `de`, **Then** the current request or session resolves to `de` until the override is cleared. +4. **Given** an unsupported locale value is supplied through the temporary override or persisted input, **When** the request resolves locale, **Then** the system safely falls back to English and never exposes raw keys. +5. **Given** a system-panel actor has no explicit override active, **When** they open the system panel, **Then** the request resolves from the system default and does not inherit workspace default or a persisted personal preference in v1. + +--- + +### User Story 2 - Read core governance surfaces in the chosen language (Priority: P1) + +As a workspace or platform operator, I want core governance surfaces to render consistent translated copy and glossary terms so I can make decisions without mixed-language UI fragments. + +**Why this priority**: Dashboard, Findings, Baseline Compare, and the main shell are the highest-signal operating surfaces. If they remain mixed-language, the locale foundation is not credible. + +**Independent Test**: Open the shell, dashboard, Findings, Baseline Compare, and one representative tenant/workspace management table in both `en` and `de`, and verify that headings, status labels, actions, empty states, and relative-time helper text match the resolved locale. + +**Acceptance Scenarios**: + +1. **Given** the resolved locale is `de`, **When** an operator opens the dashboard and Findings pages, **Then** navigation labels, headings, actions, and empty-state/helper text render in German using the canonical glossary. +2. **Given** the resolved locale is `en`, **When** the same operator opens Baseline Compare and the representative management tables, **Then** those surfaces render English labels and status text with unchanged workflow semantics. +3. **Given** a translation key is missing in German for an in-scope surface, **When** the page renders, **Then** the surface falls back to English instead of showing a raw translation key. + +--- + +### User Story 3 - Keep notifications and machine-readable artifacts truthful at the same time (Priority: P1) + +As an operator or customer-safe reader, I want notifications and viewer shell copy to follow my language while exports, audit entries, and machine-readable report content remain stable. + +**Why this priority**: Supportive system feedback and read-only report consumption are part of the real product experience, but they must not compromise machine-format stability or audit truth. + +**Independent Test**: Trigger representative notifications and validation errors in `de` and `en`, open a customer-safe review/report viewer, and verify that UI shell copy localizes while exported/report payloads and audit records stay invariant. + +**Acceptance Scenarios**: + +1. **Given** the resolved locale is `de`, **When** an operator triggers a representative notification or validation error on an in-scope surface, **Then** the message renders in German. +2. **Given** the resolved locale is `de`, **When** a customer-safe reader opens an existing review or report surface, **Then** the viewer chrome and helper labels render in German while underlying machine-readable content stays unchanged. +3. **Given** the same action produces an audit entry, CSV, JSON, or stored machine-readable artifact, **When** the localized UI renders around it, **Then** the artifact content and identifiers remain stable and non-localized. + +### Edge Cases + +- Unsupported locale input must never break request rendering or show raw translation keys. +- Livewire requests must preserve the already-resolved locale instead of silently reverting to the app default. +- Clearing a personal locale preference must return the user to workspace-default behavior, not leave a stale session override in place. +- A user outside any active workspace must still resolve locale safely from explicit override, personal preference, or system default. +- Global-search scope, filter semantics, and authorization outcomes must remain unchanged regardless of locale. +- Relative time labels must localize correctly without mutating stored timestamps or serialized API/export values. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes runtime presentation behavior and writes one workspace-bound user-owned preference plus one workspace-owned default, but it introduces no new Microsoft Graph path, no provider dispatch change, and no new queued workflow family. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new bounded resolver and two-locale state set because current operator workflows already need a deterministic language source. A narrower page-local translation effort would not solve current-release truth. + +**Constitution alignment (XCUT-001):** All in-scope panels, notifications, and translated core surfaces must consume the same locale resolver contract. No page may invent a second locale source, and the system panel remains on the explicit-override plus system-default subset in v1. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Localization must improve decision-first readability without exposing support/raw data or changing current disclosure boundaries. + +**Constitution alignment (PROV-001):** Platform vocabulary remains platform-core. Localization translates the vocabulary but does not rename provider-specific identifiers or alter provider payload truth. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes with minimal fixtures and no browser-lane expansion. + +**Constitution alignment (RBAC-UX):** Language selection MUST NOT alter workspace or tenant access checks, wrong-plane 404 handling, or membership/capability semantics. + +**Constitution alignment (BADGE-001):** Existing status badges and state labels may be translated, but they must continue to use shared badge/state semantics rather than page-local language mappings. + +**Constitution alignment (UI-FIL-001):** The slice extends existing Filament pages, resources, widgets, and the current auth surface. It must not create a custom localization panel or second shell. + +**Constitution alignment (UI-NAMING-001):** Primary operator labels remain domain-specific and translate the same canonical nouns (`Finding`, `Baseline`, `Drift`, `Run`, `Alert`, `Workspace`, `Tenant`) consistently. + +**Constitution alignment (DECIDE-001):** Locale choice is made once at the shell/settings level; core governance surfaces consume it without becoming configuration pages themselves. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Existing inspect/open models and action hierarchies remain unchanged. This slice changes copy and preference controls only. + +**Constitution alignment (UI-SEM-001 / LAYER-001):** One small locale resolver and one supported-locale catalog are justified because current code lacks a single request-level language decision. No generic translation framework or theme layer is allowed. + +### Functional Requirements + +- **FR-252-001 Supported locales**: The system MUST support exactly two locales in v1: `en` and `de`. +- **FR-252-002 Deterministic precedence**: The effective locale for admin and tenant web requests MUST resolve in this order: explicit override, user preference, workspace default, system default. System-panel requests MUST resolve in this order in v1: explicit override, system default. +- **FR-252-003 Safe validation**: Unsupported or malformed locale values MUST be rejected or normalized safely and MUST fall back to English rather than rendering raw translation keys. +- **FR-252-004 User-owned preference**: The system MUST allow an authenticated workspace-bound user to save, change, or clear their own personal locale preference without affecting other users. +- **FR-252-005 Workspace-owned default**: The system MUST allow authorized workspace operators to set or clear a workspace default locale using the existing workspace settings infrastructure for admin and tenant panel inheritance only. +- **FR-252-006 Reset-to-inherited behavior**: Clearing a user preference MUST cause the locale chain to resume using workspace default or system default. +- **FR-252-007 Request-time application**: The resolved locale MUST apply consistently to normal web requests, auth flows, and Livewire requests for the in-scope panels, with the system panel using the v1 subset of explicit override plus system default only. +- **FR-252-008 Shell and auth coverage**: The system MUST localize the panel shell, navigation labels, auth/login copy, and the locale control affordance itself for the supported locales. +- **FR-252-009 Core surface coverage**: The first implementation slice MUST localize the selected high-signal governance surfaces: dashboard, Findings, Baseline Compare, representative workspace/tenant management tables, monitoring, alerts, operations support labels, and customer-safe review/report viewer shell text. +- **FR-252-010 Canonical glossary consistency**: The translated surface families MUST use one consistent glossary for core governance terms such as Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Alert, and Run. +- **FR-252-011 Notification and validation coverage**: In-scope Filament notifications, validation messages, and system helper text MUST render in the resolved locale. +- **FR-252-012 Locale-aware formatting**: In-scope date, time, number, and relative-time labels shown on operator-facing surfaces MUST respect the resolved locale. +- **FR-252-013 Controlled fallback**: Missing translations in German MUST fall back to English. Raw translation keys MUST NOT appear on the rendered UI for in-scope surfaces. +- **FR-252-014 Invariant machine formats**: CSV, JSON, audit logs, stored artifacts, machine-readable report data, IDs, and provider/raw evidence payloads MUST remain stable and non-localized. +- **FR-252-015 Authorization invariance**: Locale changes MUST NOT alter route access, wrong-plane or non-member 404 behavior, member-but-no-capability 403 behavior, global-search visibility, filter scope, tenant/workspace context, or any other RBAC outcome. +- **FR-252-016 No parallel locale sources**: In-scope pages, widgets, resources, notifications, and viewers MUST consume the shared resolved locale and MUST NOT implement page-local language sources. +- **FR-252-017 Bounded v1**: This slice MUST NOT add website localization, more than two locales, a generic preferences framework, or a broad translation program for every product surface. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| User locale control | existing panel shell or self-profile surface | none on collection | N/A | none | none | N/A | `Save language`, `Use inherited default`, `Reset override` | existing save/cancel pattern if profile/settings form is used | no | Global shell exception is intentional because locale is a true shell concern | +| Workspace default locale setting | existing workspace settings surface | existing settings navigation only | N/A | none | none | N/A | existing workspace settings save action includes locale field | existing save/cancel pattern stays authoritative | yes - through existing settings audit path | No destructive action is introduced | +| Core governance surfaces | existing dashboard, resources, and pages | unchanged | existing inspect affordances remain unchanged | unchanged | unchanged | unchanged | unchanged except translated labels and copy | N/A | no new audit requirement | Translation only; no action hierarchy change | +| Monitoring and customer-safe review/report surfaces | existing pages, resources, and viewers | unchanged | existing inspect affordances remain unchanged | unchanged | unchanged | unchanged | unchanged except translated labels and copy | N/A | no new audit requirement | Viewer chrome localizes; artifact content stays stable | + +### Key Entities *(include if feature involves data)* + +- **Resolved locale context**: Derived request-time value with the selected locale plus source (`explicit_override`, `user_preference`, `workspace_default`, `system_default`); system-panel requests only use the explicit-override or system-default subset in v1 +- **User locale preference**: Workspace-bound user-owned persisted preference for `en` or `de` +- **Workspace default locale**: Workspace-owned default locale stored through the existing settings infrastructure for admin/tenant inheritance +- **Translation catalogs**: Bounded EN/DE language files covering the selected first-wave surface families \ No newline at end of file diff --git a/specs/252-platform-localization-v1/tasks.md b/specs/252-platform-localization-v1/tasks.md new file mode 100644 index 00000000..bcb609e6 --- /dev/null +++ b/specs/252-platform-localization-v1/tasks.md @@ -0,0 +1,187 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Platform Localization v1 (DE/EN) + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts. + +## Test Governance Notes + +- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for locale precedence, request-time application, translated core surfaces, feedback localization, fallback safety, authorization invariance, and invariant machine-format behavior. +- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/`; do not widen this slice into browser or heavy-governance families. +- Reuse existing user, workspace, membership, workspace-setting, and representative governance surface fixtures. Any new helper must stay opt-in and cheap by default. +- If one late surface remains English-only for a bounded reason, record it as `document-in-feature` in close-out instead of widening scope into a broader localization program. + +## Scope Control Notes + +- Keep implementation inside one locale resolver contract, one workspace-bound user preference path, one workspace default path, current panel/auth/Livewire request application, first-wave translation coverage for shell plus high-signal governance surfaces, and invariant export/audit/raw payload behavior. +- Do not add website localization, more than two locales, a generic preferences framework, a second persisted preference store for system `PlatformUser` actors, public documentation translation, or broad email-template localization. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Lock the bounded slice, translation inventory, and validation plan before runtime edits begin. + +- [x] T001 Review the bounded slice, explicit non-goals, and outcome class in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` +- [x] T002 [P] Review locale precedence, persistence ownership, and invariance boundaries in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml` +- [x] T003 [P] Confirm the focused Sail/Pest proof commands and manual smoke expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared locale primitives that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add the supported-locale allowlist, precedence evaluation, and fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php` +- [x] T005 [P] Add the personal locale preference field and model support in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/database/migrations/*_add_preferred_locale_to_users_table.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Models/User.php` +- [x] T006 [P] Register the workspace default locale definition and persistence path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php` +- [x] T007 Thread locale application through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/app.php`, a new `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php`, and the current panel providers in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/`, with explicit override plus system default only on the system panel + +**Checkpoint**: Foundation ready. Locale precedence, persistence, and request-time application now exist without any page-local language logic. + +--- + +## Phase 3: User Story 1 - Choose and inherit language predictably (Priority: P1) 🎯 MVP + +**Goal**: Let users inherit workspace default language, override it personally, and apply a temporary explicit override from the current shell. + +**Independent Test**: Set workspace default locale, set or clear a personal preference, apply or clear an explicit override, and verify the effective locale on normal, auth, system-panel, and Livewire-backed panel requests, with the system panel using explicit override plus system default only. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add unit and feature coverage for precedence ordering, unsupported-locale fallback, reset-to-inherited behavior, auth and system-panel rendering, wrong-plane or non-member 404, member-but-no-capability 403, system-panel explicit-override plus system-default behavior, and Livewire continuity in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/LocaleResolverTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php` + +### Implementation for User Story 1 + +- [x] T009 [US1] Add the user-facing locale control to the existing shared shell surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/partials/context-bar.blade.php` and any required panel-provider wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/` +- [x] T010 [US1] Add the workspace default locale field to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` using the current workspace settings save and audit path, including clear-to-inherit behavior back to the system default +- [x] T011 [US1] Localize shell and auth copy across `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Auth/Login.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Auth/Login.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/partials/context-bar.blade.php` + +**Checkpoint**: User Story 1 is independently functional when language choice is deterministic and visible on the existing shell and settings surfaces. + +--- + +## Phase 4: User Story 2 - Read core governance surfaces in the chosen language (Priority: P1) + +**Goal**: Translate the first-wave governance surfaces so operators can work in English or German without mixed-language UI fragments. + +**Independent Test**: Open the shell, tenant dashboard, system dashboard, Findings, Baseline Compare, and representative workspace or tenant management tables in both locales and verify headings, actions, empty states, and glossary terms align with the resolved locale. + +### Tests for User Story 2 + +- [x] T012 [P] [US2] Add feature coverage for translated shell, tenant dashboard, system dashboard, and first-wave governance surface rendering and fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php` + +### Implementation for User Story 2 + +- [x] T013 [US2] Extract and add translation catalogs for shell, tenant and system dashboard surfaces, Findings, Baseline Compare, and representative management tables in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/`, and the touched page/resource/view files +- [x] T014 [US2] Localize relative-time, date, and number formatting on the in-scope dashboard and governance surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/`, and any related support presenters +- [x] T015 [US2] Keep search, filter, row-click, global-search, wrong-plane or non-member 404, member-but-no-capability 403, and scope semantics unchanged while translated labels render on the in-scope shell and governance surfaces + +**Checkpoint**: User Story 2 is independently functional when the first-wave operator surfaces render coherent EN or DE copy without changing workflow semantics. + +--- + +## Phase 5: User Story 3 - Localize feedback without changing machine truth (Priority: P1) + +**Goal**: Make notifications, validation text, monitoring, alert, and operations feedback copy, plus customer-safe viewer chrome, locale-aware while exported and audited machine-readable content stays invariant. + +**Independent Test**: Trigger representative notifications and validation messages, open a customer-safe review or report viewer, and confirm surrounding UI copy localizes while audit, export, and raw payload truth stays stable. + +### Tests for User Story 3 + +- [x] T016 [P] [US3] Add feature coverage for localized notifications, validation and helper text, customer-safe viewer chrome, and machine-format invariance in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php` + +### Implementation for User Story 3 + +- [x] T017 [US3] Localize in-scope notifications, validation/system messages, and monitoring, alert, or operations feedback copy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Livewire/`, and related view files +- [x] T018 [US3] Localize customer-safe review or report viewer shell copy while preserving invariant exports, audit values, IDs, and raw payload truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, and related viewer or widget views + +**Checkpoint**: User Story 3 is independently functional when localized feedback and viewer shells coexist with unchanged machine-readable artifact truth. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope. + +- [x] T019 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/LocaleResolverTest.php` +- [x] T020 Run the targeted feature Sail/Pest commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php` +- [x] T021 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` +- [x] T022 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` note for temporarily untranslated surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: starts immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after shared locale resolution and persistence exist. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: first shippable increment once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2 and should follow US1 because translated shell and precedence behavior must already be stable. +- **US3 (P1)**: independently testable after Phase 2 and should merge after US1 because notifications, alert and operations feedback, and viewer shells depend on the same locale foundation. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap before implementation. +- Complete the shared resolver or persistence seam before wiring multiple UI entry points that depend on it. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T002 and T003 can run in parallel after T001 confirms the bounded slice. + +### Phase 2 + +- T004, T005, and T006 can run in parallel. +- T007 should follow once resolver and persistence keys exist. + +### User Story 1 + +- T008 can run in parallel with any final Phase 2 cleanup. +- T009 and T010 can proceed in parallel once the persistence shape is stable. +- T011 should follow after the locale-control surface exists. + +### User Story 2 + +- T012 can run in parallel with final US1 validation once Phase 2 is complete. +- T013 and T014 can run in parallel after the first-wave inventory is fixed. +- T015 should follow to confirm semantics remain unchanged. + +### User Story 3 + +- T016 can run in parallel with late US2 verification. +- T017 and T018 can run in parallel once the locale foundation is stable. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **Phase 2 + User Story 1 + User Story 2 + User Story 3**. This is the smallest slice that establishes deterministic locale truth, translates the declared first-wave governance and feedback surfaces, and preserves invariant machine-readable artifacts. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate deterministic locale choice plus inheritance. +3. Deliver US2 and validate translated shell plus first-wave governance surfaces. +4. Deliver US3 and validate localized feedback, alert and operations copy, customer-safe viewer chrome, and invariant machine-readable artifacts. +5. Finish with Phase 6 validation, formatting, and feature-local close-out recording. diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md b/specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md new file mode 100644 index 00000000..ad337cdd --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-28 +**Feature**: specs/253-remove-findings-backfill-runtime-surfaces/spec.md + +## Content Quality + +- [x] No language/framework/API design leakage; concrete repo surfaces, commands, and labels are named only because this cleanup deletes those exact shipped traces. +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible traces + +## Test Governance Review + +- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` so operational-control bypass residue cannot survive the cleanup silently. +- [x] No new browser or heavy-governance family is introduced; the retained guard stays explicit, bounded, and tied to operational-control source-trace removal only. +- [x] Suite-cost outcome is net-negative: backfill-only tests, lane traces, and helper residue are removed in the same slice instead of widening shared defaults. + +## Review Outcome + +- [x] Review outcome class: `acceptable-special-case` +- [x] Workflow outcome: `keep` +- [x] Review-note location is explicit: the heavy-governance retention note lives in `spec.md`, `plan.md`, `tasks.md`, and the final preparation report. + +## Notes + +- The spec intentionally names concrete routes, commands, labels, and catalog keys because the product value of this slice is the removal of those specific repo-visible runtime surfaces. +- The slice stays small by deleting visible repair tooling only; acknowledged-status cleanup and creation-time invariant hardening remain explicit follow-up candidates. +- Validation pass complete: no clarification markers remain, LEAN-001 cleanup posture is explicit, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml b/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml new file mode 100644 index 00000000..cc644b2c --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml @@ -0,0 +1,108 @@ +version: 1 +kind: findings-backfill-runtime-surface-removal + +scope: + goal: remove findings lifecycle backfill runtime and product surfaces only + non_goals: + - acknowledged-status semantics cleanup + - creation-time finding invariant hardening + - replacement repair surface + - historical data migration + - compatibility alias or no-op command preservation + +removed_entry_points: + system_runbooks: + route: /system/ops/runbooks + removed_labels: + - Rebuild Findings Lifecycle + - Preflight + - Run… + owner_files: + - apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + - apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + tenant_findings: + route: /admin/t/{tenant}/findings + removed_labels: + - Backfill findings lifecycle + - Open operation + owner_files: + - apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + - apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + console: + removed_commands: + - tenantpilot:findings:backfill-lifecycle + - tenantpilot:run-deploy-runbooks + owner_files: + - apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + - apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php + deploy_runtime: + requirement: + - no deploy hook, runtime hook, schedule, or bootstrap path may queue or start findings lifecycle backfill after cleanup + +removed_runtime_cluster: + service: + - apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + jobs: + - apps/platform/app/Jobs/BackfillFindingLifecycleJob.php + - apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php + - apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php + operation_type: + - findings.lifecycle.backfill + +removed_registry_traces: + capabilities: + - platform.runbooks.findings.lifecycle_backfill + seeders: + - apps/platform/database/seeders/PlatformUserSeeder.php + operation_catalog: + canonical_types: + - findings.lifecycle.backfill + aliases: + - findings.lifecycle.backfill + system_console_triage: + retryable_types: + - findings.lifecycle.backfill + cancelable_types: + - findings.lifecycle.backfill + operational_controls: + notes: + - the active operational-control catalog already rejects findings.lifecycle.backfill + - remaining blocked-start branches and tests for that key must be removed rather than normalized + +retained_behavior: + findings_workflow_actions: + - triage + - start_progress + - assign + - resolve + - close + - request_exception + - reopen + guarantees: + - findings workflow status, ownership, SLA, due-date, and reviewable behavior remain unchanged + - no replacement repair or maintenance surface is introduced + +legacy_data_posture: + operation_runs: + historical rows may remain stored without new canonical alias, retry, cancel, or operator-UX guarantee + audit_logs: + historical start, blocked, completed, or failed rows may remain stored without migration or compatibility layer + +validation_expectations: + no_new_side_effects: + - no supported surface may create a new OperationRun of type findings.lifecycle.backfill + - no supported surface may dispatch the deleted backfill jobs + absence_proof: + - system runbooks page exposes no findings lifecycle backfill card or action + - tenant findings page exposes no findings lifecycle backfill header action + - supported command catalog exposes no findings lifecycle backfill command entry + - operational-control and operation-label surfaces expose no live findings lifecycle backfill trace + regression_proof: + - representative canonical findings workflow actions still succeed unchanged + lane_classification: + required: + - fast-feedback + - confidence + - heavy-governance + excluded: + - browser \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md b/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md new file mode 100644 index 00000000..772792bc --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md @@ -0,0 +1,121 @@ +# Data Model — Remove Findings Lifecycle Backfill Runtime Surfaces + +**Spec**: [spec.md](spec.md) + +This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one obsolete runtime family and the reaffirmation of the canonical findings workflow as the only supported path. + +## Existing Canonical Entities Reused + +### Finding (`findings`) + +**Purpose**: Tenant-owned findings workflow truth. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `triaged_at` +- `first_seen_at` +- `last_seen_at` +- `times_seen` +- `sla_days` +- `due_at` + +**Feature use**: +- Remains the canonical workflow truth for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable behavior. +- Continues to require both `workspace_id` and `tenant_id` as non-null ownership anchors. +- Is in scope only for regression protection, not for lifecycle redesign. + +### OperationRun (`operation_runs`) + +**Purpose**: Existing canonical execution truth for supported long-running operations. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `type` +- `status` +- `outcome` +- `context` + +**Feature use**: +- After cleanup, no supported system, tenant, CLI, or deploy/runtime path may create a new `OperationRun` with `type = findings.lifecycle.backfill`. +- Historical rows may remain stored as legacy data, but the feature does not preserve special retry, cancel, label, or alias handling for them. + +### AuditLog (`audit_logs`) + +**Purpose**: Existing audit truth for prior lifecycle-backfill starts, blocked starts, and completions. + +**Feature use**: +- No new audit action family is introduced. +- Historical rows may remain stored without new cleanup migration or compatibility layer. +- Canonical findings workflow audit behavior remains unchanged and is protected through regression testing. + +### OperationalControlActivation (`operational_control_activations`) + +**Purpose**: Existing runtime-safety truth for live operational controls. + +**Feature use**: +- The cleanup should not add or preserve a `findings.lifecycle.backfill` control key. +- Existing backfill-specific blocked-start branches and tests should be removed because the active control catalog already rejects the key. + +## Removed Runtime Families + +### FindingsLifecycleBackfillSurface (derived, non-persisted) + +**Purpose**: Describes each currently productized entry point that must disappear in the cleanup. + +**Runtime fields**: +- `surface_id` — unique identifier such as `system.ops.runbooks`, `tenant.findings.list`, `console.tenantpilot.findings.backfill-lifecycle`, or `console.tenantpilot.run-deploy-runbooks` +- `entry_type` — `runbook`, `header_action`, `command`, `deploy_hook`, `operation_label`, `capability_trace`, or `test_trace` +- `operator_label` — current visible product label such as `Rebuild Findings Lifecycle` or `Backfill findings lifecycle` +- `owner_path` — current source file that makes the surface real +- `start_seam` — shared service or registry seam that currently powers the entry point + +**Feature use**: +- Drives removal planning so the cleanup deletes the source of truth for each surface instead of only hiding one page affordance. + +### FindingsLifecycleBackfillExecutionCluster (derived, non-persisted) + +**Purpose**: The dedicated runtime chain that currently starts, queues, and finalizes lifecycle backfill. + +**Current members**: +- `FindingsLifecycleBackfillRunbookService` +- `TenantpilotBackfillFindingLifecycle` +- `TenantpilotRunDeployRunbooks` +- `BackfillFindingLifecycleJob` +- `BackfillFindingLifecycleWorkspaceJob` +- `BackfillFindingLifecycleTenantIntoWorkspaceRunJob` + +**Lifecycle rule**: +- The cluster is deleted in the same slice. No dormant flag, replacement command, or service shim is retained. + +### FindingsLifecycleBackfillTrace (derived, non-persisted) + +**Purpose**: Registry, catalog, seed, test, and doc references that still advertise lifecycle backfill as supported behavior. + +**Trace fields**: +- `trace_type` — `capability`, `seeder`, `operation_type`, `operation_alias`, `triage_support`, `control_branch`, `test`, `guard`, or `doc` +- `identifier` — exact key such as `platform.runbooks.findings.lifecycle_backfill` or `findings.lifecycle.backfill` +- `owner_path` — file that currently carries the trace +- `removal_reason` — why the trace must disappear with the runtime surface + +**Feature use**: +- Ensures cleanup removes registry and test ballast in the same slice instead of leaving the repo to advertise deleted behavior indirectly. + +## Data Ownership Notes + +- No new tables, settings, or persisted aliases are introduced. +- No migration, historical data rewrite, or archival compatibility layer is planned. +- Historical `OperationRun` and `AuditLog` rows are tolerated legacy data and do not justify preserving the removed runtime path. +- Findings remain tenant-owned and continue to require both `workspace_id` and `tenant_id` as canonical ownership anchors. +- Operational-control truth remains bounded to currently supported controls only; this slice should not keep a removed backfill control key alive through hidden test fixtures or service branches. + +## Removal Invariants + +- No supported path may create a new `OperationRun` with `type = findings.lifecycle.backfill`. +- No supported page, command catalog, or deploy/runtime hook may advertise lifecycle backfill as an available operator action. +- No compatibility shim, no-op command shell, or fallback alias may remain for the removed path. +- Canonical findings workflow behavior remains unchanged and continues to operate on the existing `Finding` truth. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/plan.md b/specs/253-remove-findings-backfill-runtime-surfaces/plan.md new file mode 100644 index 00000000..97f76591 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/plan.md @@ -0,0 +1,237 @@ +# Implementation Plan: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Branch**: `253-remove-findings-backfill-runtime-surfaces` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Remove all shipped findings lifecycle backfill runtime and product surfaces by deleting the owning backfill service, jobs, commands, and registry traces instead of hiding labels locally. +- Preserve the normal findings workflow exactly as-is for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior, with regression proof kept explicit. +- Keep the slice narrow: acknowledged-status cleanup and creation-time lifecycle invariant hardening remain separate follow-up specs, and OperationRun semantics are touched only by removing one obsolete runbook path rather than inventing a new run UX abstraction. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` +**Storage**: PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned +**Testing**: Pest feature tests plus narrow unit and guard coverage +**Validation Lanes**: fast-feedback, confidence, heavy-governance +**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` surfaces, platform `/system` surfaces, and Artisan command/runtime entry points +**Project Type**: web +**Performance Goals**: after cleanup, no supported surface may enqueue or start `findings.lifecycle.backfill`; surviving findings workflows retain their current performance profile; the slice should reduce runtime and test surface rather than add new overhead +**Constraints**: LEAN-001 replacement over compatibility shims; no replacement repair surface; no findings semantics redesign; no data migration; preserve current `404` vs `403` isolation semantics; no new Filament panel/provider work; no new assets or `filament:assets` deploy changes +**Scale/Scope**: 1 cleanup slice touching 3 operator-facing surfaces, 2 console entry points, 1 shared runbook service cluster, 3 dedicated jobs, capability/operation/triage traces, and the backfill-specific test and docs footprint + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament plus existing shared action and run UX primitives +- **Shared-family relevance**: header actions, runbook launch cards, operation labeling, operational-control and paused-state messaging, command/runtime entry points +- **State layers in scope**: page, action/modal, detail/read-model +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third +- **Raw/support gating plan**: existing capability-gated diagnostics only; no new raw or support surface is introduced +- **One-primary-action / duplicate-truth control**: remove the maintenance CTA so tenant findings pages keep only canonical findings workflow actions and system pages keep only supported runbooks and supported controls +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; source-trace removal is mandatory wherever shared registries or helper paths still emit findings backfill semantics +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `App\Filament\System\Pages\Ops\Runbooks`, `App\Filament\Resources\FindingResource\Pages\ListFindings`, `App\Console\Commands\TenantpilotBackfillFindingLifecycle`, `App\Console\Commands\TenantpilotRunDeployRunbooks`, `App\Services\Runbooks\FindingsLifecycleBackfillRunbookService`, `App\Services\Runbooks\FindingsLifecycleBackfillScope`, `App\Jobs\BackfillFindingLifecycleJob`, `App\Jobs\BackfillFindingLifecycleWorkspaceJob`, `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`, `App\Support\OperationCatalog`, `App\Support\Auth\PlatformCapabilities`, `App\Services\SystemConsole\OperationRunTriageService`, `App\Support\Livewire\TrustedState\TrustedStatePolicy`, `App\Support\Ui\ActionSurface\ActionSurfaceExemptions`, `Database\Seeders\PlatformUserSeeder`, and the related findings/runbook/console/control tests +- **Shared abstractions reused**: `UiEnforcement`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `OperationRunLinks`, `SystemOperationRunLinks`, `OperationRunService`, `OperationCatalog`, `AuditRecorder`, and `WorkspaceAuditLogger` +- **New abstraction introduced? why?**: none; this is a subtractive cleanup that removes one bounded operation family and its traces +- **Why the existing abstraction was sufficient or insufficient**: the existing shared abstractions are sufficient for surviving findings workflows and surviving operations. The cleanup must converge those shared families back to supported product truth instead of layering a replacement backfill path or compatibility wrapper on top. +- **Bounded deviation / spread control**: none; where a shared catalog, capability registry, triage list, or test helper still names `findings.lifecycle.backfill`, the source trace itself must be removed instead of locally masked + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: existing shared OperationRun UX layer for surviving operation types only +- **Delegated UX behaviors**: `N/A` for the removed findings lifecycle backfill path after cleanup; queued toast, `View run` or `Open operation` links, dedupe messaging, browser events, and terminal notifications remain unchanged for every other operation type +- **Surface-owned behavior kept local**: none for the removed path +- **Queued DB-notification policy**: `N/A` for the removed path +- **Terminal notification path**: existing central lifecycle mechanism for surviving operations remains unchanged +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: `N/A` +- **Neutral platform terms / contracts preserved**: `N/A` +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Read/write separation: PASS - the slice removes shipped write entry points and introduces no new mutating path +- Inventory-first / snapshots-second: PASS - inventory, backups, and snapshots are untouched +- Graph contract path: PASS - no Microsoft Graph surface changes are involved +- Deterministic capabilities: PASS - one platform capability constant and its seeder grant are removed rather than expanded; no new capability namespace is introduced +- RBAC-UX: PASS - `/system` remains platform-only, `/admin/t/{tenant}` remains tenant-scoped, non-members stay `404`, in-scope users missing capability on surviving actions stay `403`, and cleanup must not widen or hide unrelated authorization semantics +- Workspace isolation / tenant isolation: PASS - findings remain tenant-owned with required `workspace_id` and `tenant_id` anchors; no new cross-tenant surface or leakage path is introduced +- OperationRun observability / Ops-UX: PASS - the feature removes one `OperationRun` start surface only; no new run type, no new feedback surface, and no local start UX dialect are introduced +- OperationRun lifecycle ownership: PASS - no new lifecycle transition path is introduced; surviving operations remain service-owned +- Automation / locking: PASS - queued backfill runtime paths are deleted rather than extended +- Data minimization: PASS - no new persisted data or audit payload family is introduced +- Test governance (`TEST-GOV-001`): PASS - proof remains in narrow feature plus unit lanes, with one retained heavy-governance source-scanning guard kept explicit because operational-control bypass residue must still be blocked after the control key and runbook service are removed +- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the feature removes an obsolete runtime family and adds no new abstraction layer +- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or alias layer is introduced +- Behavioral state (`STATE-001`): PASS - no new status or reason family is added; acknowledged cleanup remains a separate follow-up +- UI semantics (`UI-SEM-001`): PASS - no new presentation taxonomy is added; labels disappear together with the runtime path +- Shared pattern first (`XCUT-001`): PASS - the cleanup converges shared runbook, action, audit, and operation-label families by deletion instead of creating a parallel exception path +- Provider boundary (`PROV-001`): PASS - no provider/platform seam changes are introduced +- V1 explicitness / few layers (`V1-EXP-001`, `LAYER-001`): PASS - the narrowest solution is direct replacement and deletion, not shims or wrappers +- Bloat check (`SPEC-DISC-001`, `BLOAT-001`): PASS - the slice is explicitly subtractive and keeps broader semantics work separate +- Filament-native UI (`UI-FIL-001`): PASS - touched surfaces stay native Filament pages and actions; no custom UI framework or asset registration is needed +- Global search rule: PASS - no new searchable resource is added, and this cleanup only removes a header action from the existing findings resource rather than changing its search contract +- Panel/provider registration: PASS - Filament v5 remains on Livewire v4, and no panel or provider registration change is planned; Laravel 12 provider registration remains in `bootstrap/providers.php` if ever needed later +- Asset strategy: PASS - no new panel or shared assets are planned, so no additional `filament:assets` deploy work is introduced + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature for system runbook removal, tenant findings action removal, console-entry removal, and findings workflow regression; Unit or guard coverage for operation catalog, capability, triage-list, and source-scanning trace cleanup +- **Affected validation lanes**: fast-feedback, confidence, heavy-governance +- **Why this lane mix is the narrowest sufficient proof**: the business truth is server-side surface removal plus unchanged canonical findings workflow behavior. Fast-feedback and confidence cover the runtime behavior directly. One retained heavy-governance source-scanning guard is still needed because the cleanup removes an operational-control key and runbook-service entry point that the existing guard already protects from ad-hoc bypass drift. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- **Fixture / helper / factory / seed / context cost risks**: remove or collapse backfill-only fixtures, control activations, and command test setup; keep findings workflow fixtures opt-in and local to regression tests +- **Expensive defaults or shared helper growth introduced?**: no; expected net-negative because a dedicated backfill family and its source-scanning guard expectations should shrink +- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is added, but one existing heavy-governance guard remains explicit in the plan because the cleanup still depends on `tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- **Surface-class relief / special coverage rule**: standard-native-filament and monitoring-state-page relief are sufficient; assert absence and no side effects rather than browser-only choreography +- **Closing validation and reviewer handoff**: reviewers should rerun the targeted commands, including the retained heavy-governance bypass guard, verify that no UI, CLI, deploy, control, catalog, capability, triage, trusted-state, or action-surface trace remains for `findings.lifecycle.backfill`, and confirm representative triage, assignment, progress, resolve, risk acceptance, ownership, SLA, and due-date findings flows still behave unchanged +- **Budget / baseline / trend follow-up**: expected net decrease in focused feature and guard surface +- **Review-stop questions**: did implementation leave a no-op compatibility shell, keep a hidden operation alias, preserve a dead blocked-state branch after the control key was already removed, or widen into acknowledged-status cleanup or lifecycle invariant redesign? +- **Escalation path**: reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the cleanup is a bounded subtractive slice; deeper findings semantics and creation-time invariant work already have explicit follow-up candidates instead of hidden spillover + +## Project Structure + +### Documentation (this feature) + +```text +specs/253-remove-findings-backfill-runtime-surfaces/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── findings-backfill-runtime-surface-removal.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Console/Commands/ +│ │ ├── TenantpilotBackfillFindingLifecycle.php +│ │ └── TenantpilotRunDeployRunbooks.php +│ ├── Filament/ +│ │ ├── Resources/FindingResource/Pages/ListFindings.php +│ │ └── System/Pages/Ops/Runbooks.php +│ ├── Jobs/ +│ │ ├── BackfillFindingLifecycleJob.php +│ │ ├── BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php +│ │ └── BackfillFindingLifecycleWorkspaceJob.php +│ ├── Services/ +│ │ ├── Runbooks/FindingsLifecycleBackfillRunbookService.php +│ │ ├── Runbooks/FindingsLifecycleBackfillScope.php +│ │ └── SystemConsole/OperationRunTriageService.php +│ └── Support/ +│ ├── Auth/PlatformCapabilities.php +│ ├── Livewire/TrustedState/TrustedStatePolicy.php +│ ├── OperationCatalog.php +│ └── Ui/ActionSurface/ActionSurfaceExemptions.php +├── database/seeders/PlatformUserSeeder.php +└── tests/ + ├── Feature/ + │ ├── Console/Spec113/DeployRunbooksCommandTest.php + │ ├── Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php + │ ├── Findings/OperationalControlFindingsBackfillGateTest.php + │ ├── OperationalControls/NoAdHocOperationalControlBypassTest.php + │ ├── System/OpsControls/OperationalControlManagementTest.php + │ └── System/OpsRunbooks/ + │ ├── FindingsLifecycleBackfillPreflightTest.php + │ ├── FindingsLifecycleBackfillStartTest.php + │ ├── OpsUxStartSurfaceContractTest.php + │ └── OperationalControlRunbookGateTest.php + └── Unit/Support/OperationalControls/OperationalControlCatalogTest.php +``` + +**Structure Decision**: Single Laravel web application. The implementation slice is subtractive and should stay inside the existing system page, tenant findings page, console command, shared runbook service, registry, and test directories instead of creating a new namespace or framework. + +## Complexity Tracking + +No constitution violations are expected. This feature should reduce permanent complexity by deleting a productized repair path, its queue jobs, and its trace surface. + +## Proportionality Review + +- **Current operator problem**: the repo still productizes a findings lifecycle repair path through runbooks, tenant findings actions, commands, operation labels, and tests even though current finding generators already write the required lifecycle fields directly +- **Existing structure is insufficient because**: a local hide or feature flag would leave the service, jobs, commands, operation labels, triage support, and backfill-only tests alive, so the product would keep advertising deleted behavior through other surfaces +- **Narrowest correct implementation**: delete the owning backfill service and job cluster, remove the UI and command entry points, remove capability and operation traces, and keep canonical findings workflows unchanged with targeted regression proof +- **Ownership cost created**: negative; maintenance burden, suite cost, and operator confusion should all decrease +- **Alternative intentionally rejected**: compatibility shims, no-op deploy command shells, historical alias preservation, or folding acknowledged-status cleanup and lifecycle invariant hardening into the same slice +- **Release truth**: current-release truth + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/research.md` + +Goals: +- Confirm the narrowest deletion boundary across system UI, tenant UI, CLI, deploy/runtime hooks, jobs, registry traces, and test artifacts. +- Confirm that LEAN-001 requires removal over compatibility shims for the backfill service, commands, operation aliases, and historical run UX support. +- Record the partial operational-control cleanup already present in the repo so implementation removes remaining dead branches instead of reintroducing the control key. +- Keep acknowledged-status cleanup and creation-time lifecycle invariants explicitly deferred while documenting the regression contract for normal findings workflows. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` + +Design focus: +- Remove the `Rebuild Findings Lifecycle` system runbook card, preflight, run modal, and related `OperationRun` launch UX from `Runbooks.php`. +- Remove the tenant findings header action `Backfill findings lifecycle` and its queued, paused, and `Open operation` messaging from `ListFindings.php` while leaving canonical findings workflow actions untouched. +- Delete `TenantpilotBackfillFindingLifecycle`, and delete `TenantpilotRunDeployRunbooks` if its only shipped responsibility remains lifecycle backfill. +- Delete `FindingsLifecycleBackfillRunbookService` and the dedicated workspace plus tenant jobs that exist only to support the removed runtime path. +- Remove `findings.lifecycle.backfill` traces from `PlatformCapabilities`, `PlatformUserSeeder`, `OperationCatalog`, `OperationRunTriageService`, test guards, docs, and backfill-specific feature tests. +- Remove `FindingsLifecycleBackfillScope.php`, the backfill-specific trusted-state markers in `TrustedStatePolicy.php`, and the backfill-specific action-surface evidence in `ActionSurfaceExemptions.php` so no hidden surface or helper residue still implies support. +- Treat current operational-control residue as cleanup input: the control catalog already rejects `findings.lifecycle.backfill`, so remaining blocked-start branches and tests should be removed rather than normalized. +- Keep historical `OperationRun` and `AuditLog` rows as tolerated legacy data without adding alias layers, migrations, or new UI promises. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Remove the system runbook entry points and the tenant findings header action for lifecycle backfill. +- Delete the dedicated CLI and deploy/runtime command entry points for lifecycle backfill. +- Delete the shared runbook service and the dedicated workspace or tenant backfill jobs. +- Remove capability, seeder, operation-catalog, and system-console triage traces for `findings.lifecycle.backfill`. +- Rewrite or delete backfill-only tests and docs, then add narrow absence plus regression coverage that proves the path stays gone while canonical findings workflows still work. +- Verify no compatibility shim, no-op command shell, or replacement repair path survives the cleanup. + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape must still be net-negative complexity, contain no new persistence or abstraction, preserve Livewire v4 plus Filament v5 conventions, leave provider registration unchanged in `bootstrap/providers.php`, keep global search behavior unchanged, and keep the validation burden inside fast-feedback and confidence plus one explicit retained heavy-governance bypass guard only. diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md b/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md new file mode 100644 index 00000000..a1d7cb67 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart — Remove Findings Lifecycle Backfill Runtime Surfaces + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing platform-operator and tenant-user factories available for targeted tests +- Existing findings workflow fixtures available for regression coverage + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Run targeted tests after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/system` as a platform operator and confirm `/system/ops/runbooks` no longer shows `Rebuild Findings Lifecycle`, its preflight action, or its run modal. +2. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm there is no `Backfill findings lifecycle` header action while canonical findings workflow actions still render according to current capability rules. +3. Open `/system/ops/controls` and confirm there is no findings lifecycle backfill control row, action, or history affordance. +4. Check the supported Artisan command catalog and confirm `tenantpilot:findings:backfill-lifecycle` is gone, and `tenantpilot:run-deploy-runbooks` is also gone if backfill was its only remaining shipped responsibility. +5. Exercise representative findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, and `Risk accept` and confirm the existing workflow behavior is unchanged. +6. Open Monitoring or Operations and confirm no supported surface can create a new `findings.lifecycle.backfill` run; historical rows, if any remain in local data, must not receive new special retry or cancel affordances. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament pages and actions. +- No panel or provider registration changes are planned; `bootstrap/providers.php` remains the authoritative location if any provider work is ever needed later. +- No new global-search resource, searchable surface, or global-search contract change is involved. +- No new asset pipeline work is expected, so there is no added `filament:assets` deployment step. +- LEAN-001 applies directly: the cleanup should delete obsolete runtime surfaces rather than keeping aliases, no-op command shells, or compatibility branches for historical data. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/research.md b/specs/253-remove-findings-backfill-runtime-surfaces/research.md new file mode 100644 index 00000000..665fa675 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/research.md @@ -0,0 +1,153 @@ +# Research — Remove Findings Lifecycle Backfill Runtime Surfaces + +**Date**: 2026-04-28 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded planning decisions for the findings lifecycle backfill cleanup slice. All decisions assume the current pre-production LEAN-001 posture. + +## Decision 1 — Remove source traces, not only visible buttons + +**Decision**: Delete the owning runtime sources for findings lifecycle backfill wherever the repo still starts, labels, or advertises the path. Do not treat the work as a page-local hide of the runbook card or the tenant findings header action. + +**Rationale**: +- The same path is currently sourced from `Runbooks.php`, `ListFindings.php`, `TenantpilotBackfillFindingLifecycle`, `TenantpilotRunDeployRunbooks`, `FindingsLifecycleBackfillRunbookService`, dedicated jobs, `OperationCatalog`, and `OperationRunTriageService`. +- The product-truth problem is cross-surface. Hiding only the visible buttons would leave CLI, deploy/runtime, catalog, and monitoring traces alive. +- FR-253-013 requires removing the source trace when a shared registry or helper family still emits lifecycle-backfill semantics. + +**Evidence**: +- `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` +- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` +- `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` +- `apps/platform/app/Support/OperationCatalog.php` +- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php` + +**Alternatives considered**: +- Hide the system runbook only. + - Rejected: tenant UI, CLI, deploy/runtime, and monitoring traces would still advertise supported behavior. +- Hide the tenant findings action only. + - Rejected: `/system` and runtime hooks would still keep the repair path productized. + +## Decision 2 — Delete the backfill-only runtime cluster; do not keep no-op compatibility shells + +**Decision**: Delete `TenantpilotBackfillFindingLifecycle`, delete `TenantpilotRunDeployRunbooks` if lifecycle backfill is still its only shipped responsibility, and delete the dedicated backfill service and jobs instead of leaving dormant compatibility shells. + +**Rationale**: +- LEAN-001 explicitly prefers replacement or deletion over shims in this repo. +- `TenantpilotRunDeployRunbooks` currently delegates only to the shared backfill service, so leaving it behind as a no-op would preserve false product truth. +- The dedicated workspace and tenant job chain exists only for lifecycle backfill and has no independent product purpose after cleanup. + +**Evidence**: +- `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` +- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` + +**Alternatives considered**: +- Keep the commands as deprecated wrappers that print a skip message. + - Rejected: still productizes the removed path. +- Leave the service and jobs behind behind an always-false gate. + - Rejected: dead runtime ballast is exactly what this cleanup is intended to remove. + +## Decision 3 — Preserve canonical findings workflows; defer deeper semantics cleanup + +**Decision**: Keep canonical findings workflow behavior unchanged and limit this slice to removing the backfill path and any direct references that disappear with it. Continue to treat acknowledged-status cleanup and creation-time lifecycle invariants as explicit follow-up candidates. + +**Rationale**: +- `spec-candidates.md` separates `Remove Findings Lifecycle Backfill Runtime Surfaces`, `Remove Legacy Acknowledged Finding Status Compatibility`, and `Enforce Creation-Time Finding Invariants` into distinct follow-up slices. +- The current backfill jobs mutate more than surface wiring: they normalize legacy `acknowledged` to `triaged`, fill lifecycle fields, fill SLA fields, and consolidate drift duplicates. Folding those semantics into this cleanup would widen scope beyond “remove shipped repair tooling”. +- The spec and approval rubric both require a bounded cleanup slice. + +**Evidence**: +- `docs/product/spec-candidates.md` +- `docs/product/implementation-ledger.md` +- `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` + +**Alternatives considered**: +- Merge acknowledged-status cleanup into this slice. + - Rejected: deeper workflow, badge, query, and RBAC consequences deserve their own bounded spec. +- Merge creation-time invariant hardening into this slice. + - Rejected: generator and reopen semantics hardening is broader than runtime-surface deletion and should follow after repair tooling is gone. + +## Decision 4 — Treat operational-control backfill traces as partial residue, not active product truth + +**Decision**: Remove remaining operational-control-related lifecycle-backfill branches and tests rather than trying to make the control path “consistent again”. + +**Rationale**: +- The repo already partially removed the operational-control surface for this path. `OperationalControlCatalogTest` rejects `findings.lifecycle.backfill`, and `OperationalControlManagementTest` asserts the controls page no longer renders it. +- The backfill service and some feature tests still carry `OperationalControlBlockedException` handling and blocked-start audit expectations for the removed control key. +- Re-adding the control key would widen product truth in the wrong direction. + +**Evidence**: +- `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php` +- `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` +- `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php` +- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` + +**Alternatives considered**: +- Reintroduce `findings.lifecycle.backfill` to the operational-control catalog so all traces line up. + - Rejected: that would reverse an already-desirable cleanup and keep the non-shipping feature alive. + +## Decision 5 — Historical `OperationRun` and audit rows remain tolerated legacy data without new aliases + +**Decision**: Historical `operation_runs.type = findings.lifecycle.backfill` rows and prior audit rows may remain stored, but the cleanup must not add new alias handling, new UI guarantees, or special retry or cancel semantics solely for those historical rows. + +**Rationale**: +- LEAN-001 forbids compatibility layers without production data pressure. +- `OperationRunTriageService` still treats `findings.lifecycle.backfill` as retryable and cancelable. That support is part of the shipped runtime story and should disappear with the runtime path rather than being preserved for historical records. +- The spec explicitly says historical data migration and historical compatibility handling are out of scope. + +**Evidence**: +- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php` +- `apps/platform/app/Support/OperationCatalog.php` +- `specs/253-remove-findings-backfill-runtime-surfaces/spec.md` + +**Alternatives considered**: +- Preserve the operation type and alias so old runs keep a polished label forever. + - Rejected: adds a compatibility obligation for non-shipping behavior. +- Add a migration to scrub old rows. + - Rejected: out of scope and not justified in pre-production. + +## Decision 6 — Validation stays in fast-feedback and confidence lanes with absence-focused proof + +**Decision**: Replace backfill-specific start, preflight, gate, and command tests with narrow absence and regression coverage. Keep representative findings workflow regression explicit and do not add browser or heavy-governance coverage. + +**Rationale**: +- The new business truth is absence of the repair path plus continuity of canonical findings workflows. +- Existing backfill tests already prove the current path thoroughly; the replacement proof should be just as targeted, but around absence and unaffected workflows. +- Browser coverage would mostly duplicate Filament action choreography and not improve confidence on the cleanup boundaries. + +**Evidence**: +- `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php` +- `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php` +- `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php` +- `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` + +**Alternatives considered**: +- Keep the existing backfill-only tests and just rename assertions. + - Rejected: they would still preserve a product contract for a deleted runtime path. +- Add browser smoke for the deleted buttons. + - Rejected: the proving purpose is server-side absence and unchanged workflow behavior, not browser choreography. + +## Decision 7 — No panel, global-search, or asset work is part of this cleanup + +**Decision**: Keep the cleanup inside existing system and tenant surfaces. Do not change Filament panel registration, do not introduce or alter global-search behavior, and do not add asset work. + +**Rationale**: +- The affected surfaces already exist and already run on Filament v5 + Livewire v4. +- The cleanup removes a header action and a system runbook card, but it does not add a new resource, page family, or asset bundle. +- Provider registration changes in `bootstrap/providers.php` or `filament:assets` deployment work would be unrelated scope growth. + +**Evidence**: +- `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` +- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- repo conventions in `Agents.md` and `.github/copilot-instructions.md` + +**Alternatives considered**: +- Add a replacement informational page or asset-backed empty state. + - Rejected: the narrowest correct implementation is removal, not replacement UX. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/spec.md b/specs/253-remove-findings-backfill-runtime-surfaces/spec.md new file mode 100644 index 00000000..950e0458 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/spec.md @@ -0,0 +1,291 @@ +# Feature Specification: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Feature Branch**: `253-remove-findings-backfill-runtime-surfaces` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Prepare the Spec Kit feature for Remove Findings Lifecycle Backfill Runtime Surfaces as the smallest cleanup slice that removes visible findings lifecycle backfill runbooks, commands, tenant actions, capabilities, and deploy/runtime hooks while keeping normal findings workflows unchanged." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot still ships a findings lifecycle repair path through system runbooks, tenant findings actions, CLI commands, deploy hooks, operation labeling, and residual operational-control traces even though current finding generators already write the relevant lifecycle fields directly. +- **Today's failure**: Operators and maintainers can still encounter `Backfill findings lifecycle` and `Rebuild Findings Lifecycle` as supported product truth, and the repo still carries residual control and guard traces for `findings.lifecycle.backfill` even where the control page no longer renders a live card. That overstates the product, keeps pre-production repair tooling alive, and invites the wrong next action on findings and ops surfaces. +- **User-visible improvement**: Tenant and platform operators only see supported findings workflows and supported ops controls, not an internal repair action that should not ship. +- **Smallest enterprise-capable version**: Remove the lifecycle-backfill runtime surface end to end: system runbook exposure, tenant findings action exposure, supported CLI and deploy entry points, backfill-only execution plumbing, operation and control catalog traces, and dedicated tests or docs that only exist for this path. +- **Explicit non-goals**: No findings workflow redesign, no legacy `acknowledged` semantics cleanup beyond direct path-removal references, no new repair surface, no new backfill, no migration shim, no historical data migration, and no general refactor of findings lifecycle semantics. +- **Permanent complexity imported**: Net negative. The slice removes runbook-, command-, capability-, control-, catalog-, and test-surface complexity. The only enduring obligation is narrower regression coverage that proves the removed path stays gone while normal findings workflows still work. +- **Why now**: Specs 249 through 252 already promote the broader open candidates for customer review, governance inbox, commercial entitlements, and localization. Among the remaining open items, this is the smallest safe cleanup slice. It is narrower and safer than legacy `acknowledged` cleanup because it removes visible product ballast without rewriting canonical workflow semantics, and it should land before creation-time invariant hardening so the product stops shipping repair tooling first. +- **Why not local**: The backfill is exposed simultaneously through `/system`, tenant findings UI, CLI commands, deploy/runtime hooks, operational controls, operation catalog traces, capability seeding, and a dedicated test family. A one-file hide or feature-flag leaves product truth inconsistent and keeps drift alive in other entry points. +- **Approval class**: Cleanup +- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this slice is intentionally limited to visible and runtime removal only; deeper findings semantics and creation-time invariants remain explicit follow-up candidates. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12** +- **Decision**: approve + +## Selection Rationale + +- Specs 249 through 252 already exist for Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, and Platform Localization, so those candidates are no longer the next open preparation target. +- This cleanup slice is deliberately smaller and safer than `Remove Legacy Acknowledged Finding Status Compatibility` because it removes visible repair tooling without changing canonical findings workflow semantics, query semantics, badges, or RBAC language. +- This cleanup should precede `Enforce Creation-Time Finding Invariants`, because the product should first stop shipping visible repair and runtime surfaces and then harden generators so those surfaces never need to return. +- `Cross-Tenant Compare and Promotion v1` is broader and already has an older draft spec in the repo that needs refresh rather than a new small cleanup spec. +- `External Support Desk / PSA Handoff` remains deferred because current repo docs do not define a concrete external desk or PSA target. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant + canonical-view +- **Primary Routes**: + - `/system/ops/runbooks` + - `/system/ops/controls` as a regression-proof absence and source-trace cleanup surface, not a new visible removal target + - `/admin/t/{tenant}/findings` + - supported CLI and deploy/runtime entry points that currently expose findings lifecycle backfill +- **Data Ownership**: + - Tenant-owned `Finding` records remain the canonical findings workflow truth and keep required `workspace_id` and `tenant_id` anchors; this feature introduces no new persistence and no data migration. + - Workspace- and platform-owned operational traces that exist only to launch or describe findings lifecycle backfill are removed, including runbook exposure, operation and control labels, capability seeding, and dedicated repository artifacts. + - Existing reviewable findings behavior, ownership and responsibility, SLA, and due-date truth remain unchanged and are in scope only for regression protection. +- **RBAC**: + - Tenant membership remains the isolation boundary for findings visibility and findings workflow actions. + - Platform `/system` access and surviving ops-control visibility remain governed by existing platform capabilities, but the findings lifecycle backfill-specific capability is removed rather than hidden behind an alias. + - Non-members remain deny-as-not-found and in-scope members without required capability remain forbidden on surviving actions; this cleanup must not change unrelated findings or ops authorization semantics. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - the only canonical-view surfaces in scope are platform `/system` pages, which do not inherit tenant context. The tenant findings register remains tenant-scoped and keeps its current filters and default behavior. +- **Explicit entitlement checks preventing cross-tenant leakage**: Removing findings lifecycle backfill surfaces must not weaken existing workspace or tenant entitlement checks. `/system` remains platform-only, `/admin/t/{tenant}/findings` remains tenant-scoped, and no cleanup path may leak hidden tenant identity or historical backfill state to unauthorized users. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: header actions, system runbook launch surfaces, operation labels, operational-control cards, queued or blocked status messaging, command and deploy/runtime entry points +- **Systems touched**: system runbooks page, system operational controls page, tenant findings header actions, operation catalog and system-console triage labels, CLI command catalog, deploy/runtime hook surfaces, and dedicated test/docs artifacts +- **Existing pattern(s) to extend**: existing shared runbook, operational-control, action-surface, and operation-label families remain the only shared paths; this feature reduces them to supported product truth instead of extending them with a replacement repair flow +- **Shared contract / presenter / builder / renderer to reuse**: existing shared operation labels, shared start UX, `UiEnforcement`, operational-control catalogs, and action-surface guardrails remain authoritative for surviving operations; no new replacement contract is introduced for lifecycle backfill +- **Why the existing shared path is sufficient or insufficient**: the shared paths are sufficient for supported operations and existing findings workflow actions. They are not a reason to keep a pre-production repair task productized now that the product no longer needs to advertise or route operators into it. +- **Allowed deviation and why**: none +- **Consistency impact**: backfill-specific labels, buttons, help text, paused-state copy, capability names, operation labels, command support, and test expectations must disappear together so the repo stops telling two different stories about findings lifecycle truth. +- **Review focus**: reviewers must verify that no remaining UI, CLI, deploy/runtime, control, catalog, or repository artifact still treats findings lifecycle backfill as a supported product action. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: existing shared `OperationRun` start, toast, deep-link, dedupe, and terminal-notification contracts remain authoritative for surviving operations; this feature removes the `findings.lifecycle.backfill` operation type from those surfaces. +- **Delegated start/completion UX behaviors**: `N/A` for findings lifecycle backfill after cleanup. Queued toast, `View run` or `Open operation` links, dedupe messaging, and terminal notifications remain unchanged for other operation types. +- **Local surface-owned behavior that remains**: none for the removed findings lifecycle backfill path. +- **Queued DB-notification policy**: `N/A` for the removed path; no new policy is introduced. +- **Terminal notification path**: `N/A` for the removed path. +- **Exception required?**: none + +## 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?**: no +- **Boundary classification**: `N/A` +- **Seams affected**: `N/A` +- **Neutral platform terms preserved or introduced**: `N/A` +- **Provider-specific semantics retained and why**: `N/A` +- **Why this does not deepen provider coupling accidentally**: removing findings lifecycle backfill traces does not introduce or preserve any new provider-specific platform seam. +- **Follow-up path**: none + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| System ops runbooks page: remove `Rebuild Findings Lifecycle` runbook card, preflight state, and run modal | yes | Native Filament + shared runbook primitives | runbook launch, start UX, notifications | page, card, modal, action state | no | Keeps `/system` limited to supported runbooks | +| Tenant findings list: remove `Backfill findings lifecycle` header action and its queued or paused messaging | yes | Native Filament + shared action-surface primitives | header actions, operation start messaging | page, header action, modal, toast state | no | Keeps the findings register focused on canonical review workflow, not repair tooling | +| System operational controls page: keep `findings.lifecycle.backfill` absent and remove remaining control-entry residue | yes | Native Filament + shared control-card primitives | status messaging, operational control cards, audit links | page, card, action state | no | Prevents a non-shipping control key from surviving as hidden source trace or stale product truth | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| System operational controls page | Primary Decision Surface | Decide which supported runtime controls remain manageable | only supported controls, their state, and their scope | audit history for supported controls | Primary because the page still owns runtime-control decisions for live features; this slice removes one non-productized entry from that decision set | Keeps runtime-control decisions tied to shipping operations only | Removes a false pause or resume decision for a feature that should not ship | +| System ops runbooks page | Secondary Context Surface | Decide whether a supported runbook should start | only supported runbooks and their current readiness | remaining run detail and history | Not primary for findings lifecycle anymore because the repair path is removed rather than redirected | Keeps `/system` focused on real platform operations | Removes a dead-end repair option and its supporting copy | +| Tenant findings list | Primary Decision Surface | Review findings and pick the next governance action | finding status, severity, ownership, SLA, due state, and canonical workflow actions | deeper evidence, related operations, and finding history | Primary because this is where tenant operators decide what to do with findings; repair tooling never deserved co-equal prominence | Keeps findings work aligned to triage, assignment, progress, resolve, and risk governance | Removes a maintenance action that competes with the real next action | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| System operational controls page | operator/platform, support/platform | only supported controls, current state, scope, and reason | control-change history for supported controls | linked audit detail only when opened | `Adjust supported control` | no lifecycle-backfill control row, badge, or history affordance | one control list remains the source of truth for live runtime controls | +| System ops runbooks page | operator/platform | only supported runbooks, descriptions, readiness, and latest run context | run detail after opening a supported run | raw run data only in the run detail view | `Preflight` or `Run` a supported runbook | lifecycle-backfill copy, modal, and launch affordances are absent | no parallel repair truth remains in cards, toasts, or modals | +| Tenant findings list | operator/MSP | findings status, ownership, due signals, and canonical workflow actions | finding history, related operations, and review context | raw/support detail remains in existing evidence and detail surfaces | `Triage` or another canonical findings workflow action | no lifecycle-repair action or paused-state helper text | findings workflow truth stays on findings actions instead of a maintenance button | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| System operational controls page | Utility / System | Operational safety control center | Adjust a supported control | Same-page control card or modal | forbidden | Card-level secondary links only | Confirmation-protected card actions for supported controls only | `/system/ops/controls` | `/system/ops/controls` | system-plane scope, control scope, and reason | Operational controls / Operational control | only supported control keys remain visible | none | +| System ops runbooks page | Monitoring / Queue / Workbench | System runbook launcher | Preflight or run a supported runbook | Same-page action modal with run-detail links | forbidden | Page-level helper links and run links only | Explicit run modal for supported runbooks only | `/system/ops/runbooks` | `/system/ops/runs/{record}` | runbook scope and run history | Runbooks / Runbook | only supported runbooks are launchable | none | +| Tenant findings list | List / Table / Bulk | CRUD / List-first Resource | Open a finding for triage or another canonical workflow action | Full-row click to finding detail | required | Existing row `More` actions and header utilities | Existing workflow actions remain in their current grouped placements | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | tenant context, filters, status, and due-state signals | Findings / Finding | canonical findings workflow and governance truth | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| System operational controls page | Platform operator | Manage supported runtime controls only | Control center | Which runtime controls are part of shipping product truth right now? | supported controls, effective state, scope, reason, expiry | audit history for supported controls | runtime safety state, scope, expiry | TenantPilot only | Pause supported control, Resume supported control | State-changing control actions for supported controls only | +| System ops runbooks page | Platform operator | Decide whether a supported runbook should start | Workbench | Is there a supported operational action to run from here? | supported runbooks, descriptions, latest run context | linked run detail after navigation | execution readiness and recent outcome | TenantPilot only unless a surviving runbook legitimately mutates tenant state | Preflight supported runbook, Run supported runbook | Run supported runbook | +| Tenant findings list | Tenant operator | Decide how to triage or manage findings without maintenance detours | List/detail | What findings action should I take next? | status, severity, responsibility, SLA, due state, canonical workflow actions | deeper evidence, related operations, review context | lifecycle, governance validity, due attention, responsibility | TenantPilot only for findings workflow actions; no repair mutation path remains | Triage, Start progress, Assign, Resolve, Risk accept | Existing destructive-like workflow actions only | + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature, Unit, Heavy-Governance +- **Validation lane(s)**: fast-feedback, confidence, heavy-governance +- **Why this classification and these lanes are sufficient**: The slice removes operator surfaces, supported commands, catalog traces, and dedicated test artifacts while requiring regression proof that canonical findings workflows still function unchanged. Narrow feature tests prove absence on system and tenant surfaces plus removed CLI and deploy entry points. Narrow unit coverage proves catalog, control, capability, trusted-state, and action-surface traces are gone. One retained heavy-governance source-scanning guard remains necessary because the cleanup deletes an operational-control key and its owning runbook service, and the repo already treats that bypass guard as `surface-guard` coverage. +- **New or expanded test families**: focused absence and guard coverage for removed findings lifecycle backfill surfaces and traces, plus representative findings workflow regression coverage. Backfill-only preflight, start, idempotency, operational-control, and deploy-hook test families that exist only for this path are deleted or collapsed; no new heavy-governance family is introduced. +- **Fixture / helper cost impact**: low and net-negative. The feature should remove dedicated backfill fixtures, jobs, and lane-manifest references instead of adding new heavy setup. +- **Heavy-family visibility / justification**: one existing heavy-governance guard remains explicit in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` because the cleanup removes a control key and a runbook-service entry point that the guard already scans. The feature does not add a new heavy family; it keeps one existing guard visible instead of silently depending on it. +- **Special surface test profile**: standard-native-filament, monitoring-state-page +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for system and findings surfaces. Required extra proof is absence and guard coverage for CLI and catalog traces, plus regression checks for canonical findings workflow actions. +- **Reviewer handoff**: reviewers must verify removal at three layers: no system runbook or findings header action remains, no supported CLI or deploy/runtime trigger remains, and no control, capability, operation-label, or test-lane residue still advertises lifecycle backfill. They must also confirm representative triage, assignment, progress, resolve, risk-acceptance, ownership, SLA, and due-date flows still work unchanged. +- **Budget / baseline / trend impact**: expected net reduction because a dedicated backfill test family and related lane artifacts are removed. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Stop Shipping Repair Tooling (Priority: P1) + +As a platform or tenant operator, I should only see supported findings and ops actions, not a lifecycle-repair surface that the product no longer intends to ship. + +**Why this priority**: This is the primary trust and product-truth outcome. If the UI still advertises the repair path, the cleanup has failed even before deeper semantics work begins. + +**Independent Test**: Can be fully tested by opening the current system runbooks page, system operational controls page, and tenant findings list and verifying that no findings lifecycle backfill affordance remains while the ordinary findings workflow remains visible. + +**Acceptance Scenarios**: + +1. **Given** a platform operator opens the system runbooks page, **When** the page renders, **Then** there is no `Rebuild Findings Lifecycle` runbook, preflight state, or run modal. +2. **Given** a platform operator opens the system operational controls page, **When** the page renders, **Then** there is no `findings.lifecycle.backfill` control row, pause or resume affordance, or history affordance. +3. **Given** an entitled tenant operator opens the tenant findings list, **When** the page renders, **Then** there is no `Backfill findings lifecycle` header action and the existing findings workflow actions remain visible according to current capability rules. + +--- + +### User Story 2 - Remove Hidden Runtime Entry Points (Priority: P1) + +As a platform operator or deploy owner, I should not be able to trigger findings lifecycle backfill through a supported command or deploy/runtime hook once the product stops advertising the repair path. + +**Why this priority**: The cleanup is incomplete if the UI is clean but CLI or deploy surfaces still launch the same repair behavior in the background. + +**Independent Test**: Can be fully tested by checking the supported command and deploy/runtime entry points and proving that no supported path queues findings lifecycle backfill anymore. + +**Acceptance Scenarios**: + +1. **Given** the repo exposes supported maintenance commands, **When** the supported command catalog is reviewed after the cleanup, **Then** `tenantpilot:findings:backfill-lifecycle` is no longer a supported command. +2. **Given** the deploy/runtime hook path is exercised after the cleanup, **When** the hook runs, **Then** it does not queue or start findings lifecycle backfill. +3. **Given** the cleanup removes the only shipped responsibility of a generic deploy-runbooks entry point, **When** the feature lands, **Then** that entry point is removed rather than left behind as an inert compatibility shell. + +--- + +### User Story 3 - Keep Canonical Findings Workflow Unchanged (Priority: P2) + +As a tenant operator, I still need triage, assignment, in-progress, resolve, risk acceptance, ownership, SLA, due-date, and existing reviewable finding behavior to work exactly as before while the repair surface is removed. + +**Why this priority**: This slice is cleanup, not redesign. It should tighten product truth without changing the day-to-day findings workflow. + +**Independent Test**: Can be fully tested by running representative findings workflow actions after the cleanup and confirming that existing findings outcomes and reviewable behavior still work without any backfill dependency. + +**Acceptance Scenarios**: + +1. **Given** a tenant operator triages, assigns, starts progress on, resolves, or risk-accepts a finding, **When** the action succeeds, **Then** the same canonical findings workflow behavior remains intact. +2. **Given** findings already carry ownership, SLA, due-date, and reviewable context, **When** the cleanup lands, **Then** those surfaces and behaviors remain unchanged apart from the removed backfill action. +3. **Given** a reviewer uses existing finding detail and related review context, **When** the repair path is removed, **Then** no replacement repair message or workflow detour appears. + +### Edge Cases + +- Historical pre-production `OperationRun` or audit rows may still mention findings lifecycle backfill; this slice does not preserve special labels, filters, or compatibility handling for those old records. +- Removing a shared catalog or control entry must remove the source trace itself, not leave stale empty cards, headers, or hidden conditionals on system surfaces. +- If `tenantpilot:run-deploy-runbooks` exists only to launch findings lifecycle backfill, LEAN-001 forbids keeping it as a no-op compatibility shim after the backfill path is removed. +- Any test lane manifest, docs artifact, or action-surface exemption that only references findings lifecycle backfill must be cleaned in the same slice so the repo does not keep advertising deleted behavior indirectly. +- Normal findings workflow actions must remain visible and capability-gated even though one header action disappears; the cleanup must not collapse ordinary findings action hierarchy. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Microsoft Graph call, no new long-running work, and no new persisted truth. It removes an obsolete repair and runtime path across UI, CLI, deploy, and repository artifacts. Tenant-owned findings remain bound to required `workspace_id` and `tenant_id` anchors, and the cleanup must not imply any replacement repair flow. + +**Constitution alignment (LEAN-001 / PROP-001 / BLOAT-001):** This is explicitly a pre-production cleanup slice. No legacy alias, fallback reader, migration shim, dormant command, or historical fixture preservation is allowed for findings lifecycle backfill. The slice removes structure rather than adding it, and it keeps deeper semantics cleanup and creation-time invariant hardening as separate follow-up work. + +**Constitution alignment (XCUT-001):** The cleanup is cross-cutting across runbook launch surfaces, findings header actions, operation labels, operational controls, notifications, command support, and deploy/runtime hooks. Every one of those surfaces must converge on the same truth: findings lifecycle backfill is not a supported product action. + +**Constitution alignment (TEST-GOV-001):** Proof stays mostly in narrow feature and unit coverage, with one retained heavy-governance source-scanning guard for operational-control bypass residue. The change should still shrink, not grow, the suite by deleting backfill-only test families and lane references while keeping representative findings workflow regression protection explicit. + +**Constitution alignment (OPS-UX / OPS-UX-START-001):** This feature removes one `OperationRun` start surface rather than adding one. No replacement queued toast, run link, or terminal notification is introduced for findings lifecycle backfill, and shared start UX remains unchanged for every other operation type. + +**Constitution alignment (RBAC-UX):** Removing lifecycle backfill surfaces must not change current 404 versus 403 semantics for surviving findings or system actions. The feature removes the backfill-specific capability and its visibility path rather than creating capability aliases or a hidden bypass. + +**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / OPSURF-001):** Existing system and findings surfaces remain native operator surfaces, but non-canonical repair labels such as `Rebuild Findings Lifecycle` and `Backfill findings lifecycle` are removed from default-visible decision paths. The dominant next actions on those surfaces stay tied to supported runbooks and canonical findings workflow actions only. + +**Constitution alignment (PROV-001):** Not applicable. This cleanup does not introduce or deepen a shared provider or platform seam. + +### Functional Requirements + +- **FR-253-001**: The system MUST remove the system runbook entry labeled `Rebuild Findings Lifecycle` and its related preflight, run, last-run, and launch-copy surfaces from `/system/ops/runbooks`. +- **FR-253-002**: The system MUST remove the tenant findings header action labeled `Backfill findings lifecycle` and any related queued, paused, or `Open operation` messaging that exists only for that action. +- **FR-253-003**: The system MUST retire findings lifecycle backfill as a supported CLI path, including `tenantpilot:findings:backfill-lifecycle` and any deploy/runtime command that exists only to start the same backfill. +- **FR-253-004**: No deploy hook, runtime hook, schedule, or operational bootstrap path may start, queue, or advertise findings lifecycle backfill after this cleanup. +- **FR-253-005**: Backfill-specific jobs, runbook services, scope helpers, notification branches, and execution plumbing that exist only to support the removed runtime surfaces MUST be deleted rather than left dormant behind a flag, control gate, or compatibility stub. +- **FR-253-006**: Operation-catalog entries, operation-type aliases, system-console triage traces, operational-control keys, capability constants, seed data, and other repository traces that exist only for `findings.lifecycle.backfill` MUST be removed in the same slice. +- **FR-253-007**: Tests, lane manifests, docs, action-surface references, and repository artifacts that exist only to prove or describe findings lifecycle backfill preflight, start, pause, completion, or audit behavior MUST be removed or rewritten in the same slice. +- **FR-253-008**: The feature MUST NOT introduce a replacement repair surface, a new backfill, a migration shim, a fallback command, or a historical data migration. +- **FR-253-009**: Canonical findings workflows remain unchanged and in scope only for regression protection: triage, assignment, start progress, resolve, risk acceptance, ownership and responsibility, SLA, due-date, and existing reviewable finding behavior continue to work under current authorization, audit, and lifecycle rules. +- **FR-253-010**: Legacy `acknowledged` status cleanup remains out of scope except where the removed backfill path still references it directly; the broader semantics collapse remains a separate follow-up spec. +- **FR-253-011**: Creation-time lifecycle readiness remains a follow-up hardening concern; the product must not answer that concern by leaving a visible repair path or implying that a future backfill will return. +- **FR-253-012**: Tenant-owned findings keep existing `workspace_id` and `tenant_id` ownership anchors; no new persisted alias, compatibility row, or auxiliary repair truth is introduced to preserve the removed behavior. +- **FR-253-013**: If a shared UI or runtime surface currently exposes findings lifecycle backfill only because it derives from a shared catalog or registry, the cleanup MUST remove the source trace rather than hiding the entry with a local condition. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| System ops runbooks page | `app/Filament/System/Pages/Ops/Runbooks.php` | Existing header actions remain only for supported runbooks; findings lifecycle backfill actions are removed | Same-page runbook cards and run-detail links for surviving runbooks | none | none | none | none | run modals remain only for supported runbooks | unchanged for surviving runbooks | This slice removes the lifecycle-backfill card, preflight, and run flow instead of adding a replacement action | +| System operational controls page | `app/Filament/System/Pages/Ops/Controls.php` | Existing control-management actions remain only for supported controls; no findings lifecycle backfill control actions remain | Same-page control cards for supported controls | none | none | none | same-page control actions only | same-page control modals for supported controls only | unchanged for surviving controls | This slice removes the lifecycle-backfill control entry instead of hiding it behind an exception | +| Tenant findings list | `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Existing findings workflow utilities remain; `Backfill findings lifecycle` is removed | Clickable row to finding detail remains unchanged | existing row actions unchanged | existing grouped bulk actions unchanged | existing empty-state behavior unchanged | existing finding detail header actions unchanged | `N/A` | unchanged for surviving findings workflow actions | The cleanup removes one non-canonical header action and leaves the existing findings workflow action hierarchy intact | + +### Key Entities *(include if feature involves data)* + +- **Findings lifecycle backfill surface**: Any supported operator-visible or supported invocation path that starts or advertises the removed repair flow, including system runbooks, tenant findings actions, supported commands, deploy hooks, control entries, and operation labels. +- **Canonical findings workflow**: The existing tenant-owned findings lifecycle and governance actions that remain the only supported path for triage, assignment, in-progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior. +- **Backfill-only artifact**: A repository artifact such as a job, service, control key, capability constant, test, lane manifest, or doc that exists only to support or describe the removed findings lifecycle backfill path. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: System and tenant product surfaces expose zero visible launch affordances for findings lifecycle backfill after the cleanup. +- **SC-002**: Supported CLI, deploy, and runtime entry points expose zero supported paths that queue or start findings lifecycle backfill after the cleanup. +- **SC-003**: Operational-control and operation-label surfaces expose zero live product traces of `findings.lifecycle.backfill` after the cleanup. +- **SC-004**: Representative findings workflow regression validation continues to pass for triage, assignment, start progress, resolve, risk acceptance, ownership and responsibility, SLA, due-date, and existing reviewable finding behavior without any repair-tool dependency. +- **SC-005**: The dedicated backfill-only test and lane footprint decreases rather than grows as part of the cleanup slice. + +## Dependencies + +- Current findings generators already create lifecycle-ready records for normal product paths, even though invariant hardening remains a follow-up. +- Existing findings workflow and reviewable findings behavior remain the canonical operator truth described by current findings specs and runtime surfaces. +- Existing system runbook, operational-control, operation-catalog, and capability registries are the current places where lifecycle-backfill traces must be removed consistently. + +## Assumptions + +- LEAN-001 still applies because the product remains pre-production; historical findings lifecycle backfill rows, fixtures, or aliases do not justify compatibility behavior. +- Specs 249 through 252 already cover the broader open candidates for customer review, governance inbox, commercial entitlements, and localization, so this cleanup is the next best open small slice. +- `Cross-Tenant Compare and Promotion v1` should return later by refreshing its older draft spec rather than being recast as a new cleanup slice here. +- `External Support Desk / PSA Handoff` remains deferred until repo docs name a concrete external desk or PSA target. + +## Risks + +- Hidden references may still exist outside the named anchors, especially in lane manifests, docs, audit helpers, or shared catalog-derived UI surfaces. +- Removing shared catalog or capability traces too narrowly could leave stale empty cards, filters, or labels that continue to imply support for the removed path. +- If any current findings generator still depends on repair behavior implicitly, removing the visible runtime path will expose that gap immediately and will need the follow-up invariant-hardening spec rather than a reintroduced backfill. + +## Out of Scope + +- Removing legacy `acknowledged` workflow semantics beyond direct references that disappear with the backfill path +- Redesigning findings workflow actions, ownership semantics, SLA behavior, due-date semantics, or reviewable finding behavior +- Introducing a replacement repair surface, a new lifecycle backfill, or any migration shim +- Historical data migration, legacy alias preservation, or compatibility-specific handling for old backfill runs +- Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Localization, and broader cross-tenant compare work + +## Follow-up Candidates + +1. `Remove Legacy Acknowledged Finding Status Compatibility` for the deeper workflow, badge, filter, and RBAC cleanup that should happen only after visible repair and runtime surfaces are gone. +2. `Enforce Creation-Time Finding Invariants` to prove new findings are lifecycle-ready at write time so the removed repair surface never needs to return. +3. `Cross-Tenant Compare and Promotion v1` as a refreshed broader portfolio-action spec from the older draft already in the repo, not as part of this cleanup slice. +4. `External Support Desk / PSA Handoff` once repo docs define a concrete external desk target and bounded integration contract. diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/tasks.md b/specs/253-remove-findings-backfill-runtime-surfaces/tasks.md new file mode 100644 index 00000000..ef99974f --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/tasks.md @@ -0,0 +1,231 @@ +# Tasks: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Input**: Design documents from `/specs/253-remove-findings-backfill-runtime-surfaces/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/findings-backfill-runtime-surface-removal.contract.yaml` + +**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` feature + unit lanes already named in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` because the cleanup removes an operational-control key and its owning runbook-service seam. Prefer absence-focused coverage in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`, `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`. Keep the cleanup net-negative by deleting backfill-only tests instead of widening the suite. +**Operations**: This slice removes one existing `OperationRun` start family. Do not add a replacement runbook, alias, no-op command shell, or local start UX. Historical `operation_runs` and `audit_logs` rows may remain untouched, but no supported surface may create a new `findings.lifecycle.backfill` run after cleanup. +**RBAC**: Preserve current `/system` platform-only access, `/admin/t/{tenant}` tenant isolation, deny-as-not-found `404` for non-members or out-of-scope users, and `403` for in-scope capability failures on surviving actions. Remove the backfill-specific platform capability constant and seed grant without widening any unrelated authorization behavior. +**UI / Surface Guardrails**: This is a `review-mandatory` cleanup across native Filament system runbooks, native Filament system operational controls, and the tenant findings list. Keep `standard-native-filament` relief for surviving surfaces, remove the backfill affordances entirely, and do not introduce replacement helper copy, new panels, or new assets. +**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, or provider work is introduced. `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`, and `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` must converge on supported runbook and findings workflow actions only. +**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` and `US2` in parallel -> `US3` -> final cleanup and validation, because regression proof only matters after all backfill start seams and traces are removed. + +## Test Governance Checklist + +- [ ] Lane assignment stays `fast-feedback` plus `confidence`, with one explicit retained `heavy-governance` guard for operational-control bypass residue, and remains the narrowest sufficient proof for the removed runtime family. +- [ ] New or changed tests stay in focused `Feature` and `Unit` files only; no browser or new heavy-governance family is added. +- [ ] Shared helpers, factories, seeds, fixtures, and support defaults remain cheap by default; any backfill-specific setup is deleted instead of generalized. +- [ ] Planned validation commands stay limited to the targeted Sail test commands already captured in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`. +- [ ] The declared surface test profile stays `standard-native-filament` plus `monitoring-state-page` where the system runbooks or controls surfaces need explicit absence proof. +- [ ] Any material suite-footprint or follow-up note resolves in this feature as `document-in-feature` or `follow-up-spec`, not as an implicit scope expansion. + +## Phase 1: Setup (Shared Cleanup Anchors) + +**Purpose**: Lock the concrete removal inventory and proving commands before implementation starts. + +- [ ] T001 [P] Verify the source-surface inventory across `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php`, and `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- [ ] T002 [P] Verify the runtime-cluster and trace inventory across `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`, `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, and `apps/platform/database/seeders/PlatformUserSeeder.php` +- [ ] T003 [P] Verify the narrow validation-lane commands and manual smoke expectations in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` + +**Checkpoint**: The cleanup boundaries and proving commands are locked before any runtime file is changed. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Make the absence proof, regression anchors, and cleanup inventory explicit before deleting shared runtime seams. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [ ] T004 [P] Lock the surface-removal proof plan across `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, and `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` +- [ ] T005 [P] Lock the registry, capability, and retained heavy-governance bypass proof plan across `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, and `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- [ ] T006 [P] Audit canonical findings workflow and authorization regression anchors across `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php` +- [ ] T007 [P] Verify the backfill-only cleanup targets across `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php`, `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, and `docs/HANDOVER.md` + +**Checkpoint**: Absence-proof files, regression anchors, and cleanup-only artifacts are explicit and ready for bounded implementation work. + +--- + +## Phase 3: User Story 1 - Stop Shipping Repair Tooling (Priority: P1) 🎯 MVP + +**Goal**: Remove the visible lifecycle-backfill affordances from system and tenant operator surfaces so the product only presents supported findings and ops actions. + +**Independent Test**: Open `/system/ops/runbooks`, `/system/ops/controls`, and `/admin/t/{tenant}/findings` and verify there is no lifecycle-backfill card, control trace, or header action while the surviving findings workflow actions still render under current authorization rules. + +### Tests for User Story 1 + +- [ ] T008 [P] [US1] Add system runbook absence coverage for the removed card, preflight state, modal, and last-run copy in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php` +- [ ] T009 [P] [US1] Add tenant findings absence coverage for the removed header action and backfill-only `Open operation` messaging in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` +- [ ] T010 [P] [US1] Add system operational-control absence coverage for removed `findings.lifecycle.backfill` surface traces in `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php` + +### Implementation for User Story 1 + +- [ ] T011 [US1] Remove the lifecycle-backfill runbook card, preflight action, run modal, and last-run display from `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` and `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php` +- [ ] T012 [US1] Remove the tenant findings lifecycle-backfill header action and its backfill-only queued, paused, and link copy from `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- [ ] T013 [US1] Reconcile surface-level authorization continuity for surviving system and tenant actions in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php` + +**Checkpoint**: User Story 1 is independently functional and the visible repair tooling is gone from system and tenant operator surfaces. + +--- + +## Phase 4: User Story 2 - Remove Hidden Runtime Entry Points (Priority: P1) + +**Goal**: Remove every supported command, deploy hook, runtime service, job, and shared registry trace that can still start or advertise findings lifecycle backfill. + +**Independent Test**: Review the supported command surface, shared runtime seams, operation catalog, triage helpers, capability registry, and seeder grants and verify that no supported path can queue or describe `findings.lifecycle.backfill` anymore. + +### Tests for User Story 2 + +- [ ] T014 [P] [US2] Add command-removal coverage for the missing lifecycle-backfill CLI and deploy entry points in `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` +- [ ] T015 [P] [US2] Add operation-catalog and capability trace removal guards in `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php` and `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` +- [ ] T016 [P] [US2] Add operational-control, triage, and retained bypass-guard residue coverage for removed backfill traces in `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`, and `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php` + +### Implementation for User Story 2 + +- [ ] T017 [US2] Delete the supported CLI and deploy/runtime entry points in `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` and `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- [ ] T018 [US2] Delete the dedicated backfill runtime cluster in `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`, `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`, and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` +- [ ] T019 [US2] Remove `findings.lifecycle.backfill` registry, authorization, and trusted-state traces from `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, and `apps/platform/database/seeders/PlatformUserSeeder.php` +- [ ] T020 [US2] Remove or rewrite backfill-only command, control, and action-surface expectations in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, and `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + +**Checkpoint**: User Story 2 is independently functional and no supported runtime or registry path can start or advertise lifecycle backfill. + +--- + +## Phase 5: User Story 3 - Keep Canonical Findings Workflow Unchanged (Priority: P2) + +**Goal**: Preserve canonical findings workflow behavior and authorization semantics while the repair path is removed. + +**Independent Test**: Run representative triage, assignment, start progress, resolve, and risk-accept flows after the cleanup and confirm the same tenant isolation plus `404` versus `403` semantics still hold for surviving findings and system surfaces. + +### Tests for User Story 3 + +- [ ] T021 [P] [US3] Add representative findings workflow regression coverage for triage, assignment, start progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable continuity in `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- [ ] T022 [P] [US3] Add explicit surviving-surface authorization regression assertions inside `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php` + +### Implementation for User Story 3 + +- [ ] T023 [US3] Reconcile surviving findings workflow fixtures and assertions so they no longer depend on deleted backfill helpers in `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, and `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` + +**Checkpoint**: User Story 3 is independently functional and the canonical findings workflow remains unchanged after the backfill cleanup. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Remove backfill-only repository residue, keep docs and lane support honest, and run the narrow validation workflow. + +- [ ] T024 [P] Remove or rewrite backfill-only runbook test families in `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php`, and `apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php` +- [ ] T025 [P] Remove or rewrite backfill-only findings and control test artifacts in `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- [ ] T026 [P] Clean remaining handover and lane-support traces in `docs/HANDOVER.md`, `apps/platform/tests/Support/TestLaneManifest.php`, `scripts/platform-test-lane`, and `scripts/platform-test-report` +- [ ] T027 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the cleanup across `apps/platform/app/`, `apps/platform/tests/`, and `apps/platform/database/seeders/PlatformUserSeeder.php` +- [ ] T028 [P] Run the targeted surface-removal Pest command from `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` against `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, and `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` +- [ ] T029 [P] Run the targeted registry and workflow Pest commands from `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` against `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- [ ] T030 Run final residue searches for `findings.lifecycle.backfill`, `Backfill findings lifecycle`, `Rebuild Findings Lifecycle`, `FindingsLifecycleBackfill`, and `TenantpilotBackfillFindingLifecycle` across `apps/platform/app/`, `apps/platform/resources/`, `apps/platform/tests/`, `apps/platform/database/`, and `docs/HANDOVER.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the concrete inventory plus validation commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, regression anchors, and cleanup-only artifacts are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and is part of the MVP delivery. +- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it targets the hidden runtime and registry seams behind the removed surfaces. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because workflow regression only proves the right thing once every backfill start surface is gone. +- **Polish (Phase 6)**: Depends on all desired user stories being complete so backfill-only tests, docs, and residue searches can be cleaned once. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: No dependencies beyond Foundational. +- **US3**: Depends on US1 and US2. + +### Within Each User Story + +- Add or update the story tests first and confirm they fail before cleanup edits are considered complete. +- Remove source traces instead of hiding the backfill path locally on one page. +- Do not keep compatibility aliases, no-op commands, replacement repair surfaces, or historical row UX promises. +- Keep acknowledged-status cleanup and creation-time lifecycle invariant hardening out of scope for this feature. + +### Parallel Opportunities + +- `T001`, `T002`, and `T003` can run in parallel during Setup. +- `T004`, `T005`, `T006`, and `T007` can run in parallel during Foundational work. +- `T008`, `T009`, and `T010` can run in parallel for User Story 1, followed by `T011` and `T012`, before reconciling continuity in `T013`. +- `T014`, `T015`, and `T016` can run in parallel for User Story 2, followed by `T017`, `T018`, and `T019`, before reconciling backfill-only expectations in `T020`. +- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete. +- `T024`, `T025`, and `T026` can run in parallel during cross-cutting cleanup. +- `T028` and `T029` can run in parallel during final validation. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T008 apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php +T009 apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php +T010 apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php + +# User Story 1 implementation after the tests are in place +T011 apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php +T012 apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T014 apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php +T015 apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php + apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php +T016 apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php + apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php + apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php + +# User Story 2 implementation after the tests are in place +T017 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php +T018 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + apps/platform/app/Jobs/BackfillFindingLifecycle*.php +T019 apps/platform/app/Support/OperationCatalog.php + apps/platform/app/Services/SystemConsole/OperationRunTriageService.php + apps/platform/app/Support/Auth/PlatformCapabilities.php + apps/platform/database/seeders/PlatformUserSeeder.php +``` + +## Parallel Example: Cross-Story Delivery After Foundational + +```bash +# Visible surfaces and hidden runtime traces can be removed in parallel after Phase 2 +T011-T013 apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php + apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + related surface tests +T017-T020 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php + apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + apps/platform/app/Jobs/BackfillFindingLifecycle*.php + apps/platform/app/Support/OperationCatalog.php + apps/platform/app/Services/SystemConsole/OperationRunTriageService.php + apps/platform/app/Support/Auth/PlatformCapabilities.php + apps/platform/database/seeders/PlatformUserSeeder.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 and 2) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Run `T027`, `T028`, and `T030` before widening into workflow-regression cleanup. + +### Incremental Delivery + +1. Lock the removal inventory and proving commands. +2. Remove the visible runbook, findings action, and control-surface traces. +3. Remove the hidden CLI, deploy-hook, service, job, capability, catalog, and triage seams. +4. Prove the canonical findings workflow and authorization semantics still behave the same. +5. Clean backfill-only tests and docs, then finish with Pint plus the targeted Pest commands. + +### Parallel Team Strategy + +1. One contributor can own the visible surface cleanup (`US1`) while another owns the command, runtime, and registry cleanup (`US2`) after Phase 2. +2. Once both P1 stories land, a focused pass can own the workflow-regression slice (`US3`) without reopening runtime-surface decisions. +3. A final pass can remove backfill-only test or docs residue and run the narrow validation commands. + +--- + +## Notes + +- Suggested MVP scope: Phase 1 through Phase 4 only. Visible-surface removal without runtime-cluster removal is not sufficient for this feature. +- Explicit non-goals for implementation remain: legacy `acknowledged` status cleanup, creation-time lifecycle invariant hardening, a replacement repair surface, historical data migration, and compatibility aliases or no-op command shells. +- Follow-up candidates remain the same as the prepared spec: `Remove Legacy Acknowledged Finding Status Compatibility` and `Enforce Creation-Time Finding Invariants`. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths. \ No newline at end of file -- 2.45.2 From b511b0837116d16af66e94c315923292a4513352 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 07:34:39 +0000 Subject: [PATCH 3/7] feat: remove findings acknowledged compatibility and unify canonical operation types (#296) This PR removes the legacy "acknowledged" status compatibility for findings and unifies the canonical operation types (e.g., transitioning from baseline_capture to baseline.capture). It includes updated tests, models, and services to reflect these changes. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/296 --- .../Commands/PurgeLegacyBaselineGapRuns.php | 32 +- .../Pages/Reviews/CustomerReviewWorkspace.php | 15 + .../Filament/Resources/FindingResource.php | 5 - .../FindingResource/Pages/ListFindings.php | 1 - .../Filament/System/Pages/Ops/Controls.php | 5 - apps/platform/app/Models/Finding.php | 28 +- apps/platform/app/Policies/FindingPolicy.php | 5 +- .../app/Services/Auth/RoleCapabilityMap.php | 3 - .../Findings/FindingWorkflowService.php | 18 +- .../TenantReviewSectionFactory.php | 2 +- .../app/Support/Auth/Capabilities.php | 2 - .../Baselines/BaselineCompareStats.php | 1 - .../Concerns/DerivesWorkspaceIdFromTenant.php | 2 +- .../Support/Filament/FilterOptionCatalog.php | 24 +- .../platform/app/Support/OperationCatalog.php | 10 + .../database/factories/FindingFactory.php | 12 - .../contextual-help.blade.php | 15 +- .../Browser/OnboardingDraftRefreshTest.php | 20 +- .../OnboardingDraftVerificationResumeTest.php | 46 +-- .../RemoveAcknowledgedCapabilityAliasTest.php | 23 ++ .../BaselineCaptureAuditEventsTest.php | 3 +- ...aselineSnapshotNoTenantIdentifiersTest.php | 2 +- .../CaptureBaselineContentTest.php | 3 +- ...CaptureBaselineFullContentOnDemandTest.php | 3 +- .../CaptureBaselineMetaFallbackTest.php | 2 +- ...BaselineCaptureRbacRoleDefinitionsTest.php | 3 +- .../Feature/Baselines/BaselineCaptureTest.php | 27 +- ...elineCompareMatrixCompareAllActionTest.php | 7 +- .../Feature/DirectoryGroups/StartSyncTest.php | 3 +- .../SyncJobUpsertsGroupsTest.php | 3 +- .../SyncRetentionPurgeTest.php | 3 +- .../EntraAdminRolesFindingGeneratorTest.php | 13 +- ...BaselineCompareLandingStartSurfaceTest.php | 6 +- ...BaselineProfileCaptureStartSurfaceTest.php | 9 +- ...BaselineProfileCompareStartSurfaceTest.php | 9 +- .../InteractsWithFindingsWorkflow.php | 1 - .../Findings/FindingWorkflowGuardTest.php | 5 +- .../Findings/FindingsIntakeQueueTest.php | 4 +- ...eAcknowledgedCompatibilityWorkflowTest.php | 36 +++ .../Feature/Models/FindingResolvedTest.php | 42 ++- .../AuditCoverageGovernanceTest.php | 5 +- .../Monitoring/HeaderContextBarTest.php | 4 +- .../OpsUx/CanonicalViewRunLinksTest.php | 2 +- ...ackupRetentionTerminalNotificationTest.php | 3 +- .../PermissionPostureFindingGeneratorTest.php | 13 +- .../Rbac/RoleMatrix/ManagerAccessTest.php | 2 +- .../Rbac/RoleMatrix/OperatorAccessTest.php | 2 +- .../Rbac/RoleMatrix/OwnerAccessTest.php | 2 +- .../Rbac/RoleMatrix/ReadonlyAccessTest.php | 2 +- .../Support/Badges/FindingBadgeTest.php | 6 +- .../TenantRBAC/RoleDefinitionsSyncNowTest.php | 3 +- ...antReviewCanonicalControlReferenceTest.php | 29 +- .../GlobalContextShellContractTest.php | 2 +- .../tests/Unit/Badges/FindingBadgesTest.php | 2 +- .../Findings/FindingStatusSemanticsTest.php | 22 ++ .../OperationLifecyclePolicyValidatorTest.php | 25 +- .../FindingStatusFilterCatalogTest.php | 22 ++ .../GovernanceInboxSectionBuilderTest.php | 2 + .../ProductTelemetryRecorderTest.php | 23 ++ .../checklists/requirements.md | 48 +++ ...-acknowledged-compat-removal.contract.yaml | 121 ++++++++ .../data-model.md | 103 +++++++ specs/254-remove-acknowledged-compat/plan.md | 266 ++++++++++++++++ .../quickstart.md | 36 +++ .../research.md | 129 ++++++++ specs/254-remove-acknowledged-compat/spec.md | 283 ++++++++++++++++++ specs/254-remove-acknowledged-compat/tasks.md | 238 +++++++++++++++ 67 files changed, 1579 insertions(+), 269 deletions(-) create mode 100644 apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php create mode 100644 apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php create mode 100644 apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php create mode 100644 apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php create mode 100644 specs/254-remove-acknowledged-compat/checklists/requirements.md create mode 100644 specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml create mode 100644 specs/254-remove-acknowledged-compat/data-model.md create mode 100644 specs/254-remove-acknowledged-compat/plan.md create mode 100644 specs/254-remove-acknowledged-compat/quickstart.md create mode 100644 specs/254-remove-acknowledged-compat/research.md create mode 100644 specs/254-remove-acknowledged-compat/spec.md create mode 100644 specs/254-remove-acknowledged-compat/tasks.md diff --git a/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php b/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php index 95a977fe..e012742d 100644 --- a/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php +++ b/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php @@ -6,12 +6,14 @@ use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\OperationCatalog; +use App\Support\OperationRunType; use Illuminate\Console\Command; class PurgeLegacyBaselineGapRuns extends Command { protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs - {--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs} + {--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)} {--tenant=* : Limit cleanup to tenant ids or tenant external ids} {--workspace=* : Limit cleanup to workspace ids} {--limit=500 : Maximum candidate runs to inspect} @@ -99,21 +101,35 @@ public function handle(): int */ private function normalizedTypes(): array { - $types = array_values(array_unique(array_filter( + $requestedTypes = array_values(array_unique(array_filter( array_map( static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null, (array) $this->option('type'), ), ))); - if ($types === []) { - return ['baseline_compare', 'baseline_capture']; + $canonicalTypes = array_values(array_unique(array_filter(array_map( + static fn (string $type): ?string => match ($type) { + OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value, + OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value, + default => null, + }, + $requestedTypes, + )))); + + if ($canonicalTypes === []) { + $canonicalTypes = [ + OperationRunType::BaselineCompare->value, + OperationRunType::BaselineCapture->value, + ]; } - return array_values(array_filter( - $types, - static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true), - )); + return array_values(array_unique(array_merge( + ...array_map( + static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type), + $canonicalTypes, + ), + ))); } /** diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 8b41da58..520f4c52 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -16,6 +16,11 @@ use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\TablePaginationProfiles; use App\Support\ReviewPackStatus; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome; @@ -57,6 +62,16 @@ class CustomerReviewWorkspace extends Page implements HasTable protected string $view = 'filament.pages.reviews.customer-review-workspace'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.'); + } + public static function getNavigationGroup(): string { return __('localization.review.reporting'); diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index d7e1f83e..bf92c24e 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -308,8 +308,6 @@ public static function infolist(Schema $schema): Schema ? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record)) : null) ->openUrlInNewTab(), - TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'), - TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'), TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'), TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), @@ -1000,7 +998,6 @@ public static function table(Table $table): Table if (! in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) { $skippedCount++; @@ -1416,7 +1413,6 @@ public static function triageAction(): Actions\Action ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( @@ -1441,7 +1437,6 @@ public static function startProgressAction(): Actions\Action ->color('info') ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_TRIAGED, - Finding::STATUS_ACKNOWLEDGED, ], true)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php index d4796c04..633920ea 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -171,7 +171,6 @@ protected function getHeaderActions(): array if (! in_array((string) $finding->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) { $skippedCount++; diff --git a/apps/platform/app/Filament/System/Pages/Ops/Controls.php b/apps/platform/app/Filament/System/Pages/Ops/Controls.php index a5f39efa..9fe0d816 100644 --- a/apps/platform/app/Filament/System/Pages/Ops/Controls.php +++ b/apps/platform/app/Filament/System/Pages/Ops/Controls.php @@ -57,11 +57,6 @@ public static function canAccess(): bool && $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE); } - public function mount(): void - { - abort_unless(static::canAccess(), 403); - } - public function getHeader(): ?View { return view('filament.system.pages.ops.partials.controls-header', [ diff --git a/apps/platform/app/Models/Finding.php b/apps/platform/app/Models/Finding.php index cb49b575..d37c6a0f 100644 --- a/apps/platform/app/Models/Finding.php +++ b/apps/platform/app/Models/Finding.php @@ -33,8 +33,6 @@ class Finding extends Model public const string STATUS_NEW = 'new'; - public const string STATUS_ACKNOWLEDGED = 'acknowledged'; - public const string STATUS_TRIAGED = 'triaged'; public const string STATUS_IN_PROGRESS = 'in_progress'; @@ -169,10 +167,7 @@ public static function terminalStatuses(): array */ public static function openStatusesForQuery(): array { - return [ - ...self::openStatuses(), - self::STATUS_ACKNOWLEDGED, - ]; + return self::openStatuses(); } /** @@ -295,10 +290,6 @@ public static function isReopenReason(?string $reason): bool public static function canonicalizeStatus(?string $status): ?string { - if ($status === self::STATUS_ACKNOWLEDGED) { - return self::STATUS_TRIAGED; - } - return $status; } @@ -324,23 +315,6 @@ public function isRiskAccepted(): bool return (string) $this->status === self::STATUS_RISK_ACCEPTED; } - public function acknowledge(User $user): self - { - if ($this->status === self::STATUS_ACKNOWLEDGED) { - return $this; - } - - $this->forceFill([ - 'status' => self::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $user->getKey(), - ]); - - $this->save(); - - return $this; - } - public function resolve(string $reason): self { $this->forceFill([ diff --git a/apps/platform/app/Policies/FindingPolicy.php b/apps/platform/app/Policies/FindingPolicy.php index c84c2f0a..01144ad0 100644 --- a/apps/platform/app/Policies/FindingPolicy.php +++ b/apps/platform/app/Policies/FindingPolicy.php @@ -49,10 +49,7 @@ public function update(User $user, Finding $finding): Response|bool public function triage(User $user, Finding $finding): Response|bool { - return $this->canMutateWithAnyCapability($user, $finding, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE); } public function assign(User $user, Finding $finding): Response|bool diff --git a/apps/platform/app/Services/Auth/RoleCapabilityMap.php b/apps/platform/app/Services/Auth/RoleCapabilityMap.php index 49b51cdd..3f92c665 100644 --- a/apps/platform/app/Services/Auth/RoleCapabilityMap.php +++ b/apps/platform/app/Services/Auth/RoleCapabilityMap.php @@ -28,7 +28,6 @@ class RoleCapabilityMap Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_RISK_ACCEPT, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, @@ -74,7 +73,6 @@ class RoleCapabilityMap Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_RISK_ACCEPT, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, @@ -112,7 +110,6 @@ class RoleCapabilityMap Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW, diff --git a/apps/platform/app/Services/Findings/FindingWorkflowService.php b/apps/platform/app/Services/Findings/FindingWorkflowService.php index c6ac3e13..2230bc1e 100644 --- a/apps/platform/app/Services/Findings/FindingWorkflowService.php +++ b/apps/platform/app/Services/Findings/FindingWorkflowService.php @@ -46,17 +46,13 @@ public static function meaningfulActivityActionValues(): array public function triage(Finding $finding, Tenant $tenant, User $actor): Finding { - $this->authorize($finding, $tenant, $actor, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); $currentStatus = (string) $finding->status; if (! in_array($currentStatus, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) { throw new InvalidArgumentException('Finding cannot be triaged from the current status.'); } @@ -82,12 +78,9 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding { - $this->authorize($finding, $tenant, $actor, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); - if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) { + if ((string) $finding->status !== Finding::STATUS_TRIAGED) { throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.'); } @@ -369,10 +362,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { - $this->authorize($finding, $tenant, $actor, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { throw new InvalidArgumentException('Only terminal findings can be reopened.'); diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php index 7abf7599..5ba43e04 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php @@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array { $summary = $this->summary($findingsItem); $entries = collect(Arr::wrap($summary['entries'] ?? [])) - ->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true)) + ->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true)) ->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) { 'critical' => 4, 'high' => 3, diff --git a/apps/platform/app/Support/Auth/Capabilities.php b/apps/platform/app/Support/Auth/Capabilities.php index 81ec07e2..60927136 100644 --- a/apps/platform/app/Support/Auth/Capabilities.php +++ b/apps/platform/app/Support/Auth/Capabilities.php @@ -91,8 +91,6 @@ class Capabilities public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept'; - public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge'; - public const FINDING_EXCEPTION_VIEW = 'finding_exception.view'; public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage'; diff --git a/apps/platform/app/Support/Baselines/BaselineCompareStats.php b/apps/platform/app/Support/Baselines/BaselineCompareStats.php index 880333e4..ae5c8a9b 100644 --- a/apps/platform/app/Support/Baselines/BaselineCompareStats.php +++ b/apps/platform/app/Support/Baselines/BaselineCompareStats.php @@ -796,7 +796,6 @@ private static function findingAttentionCounts(Tenant $tenant): array $activeNonNewFindingsCount = Finding::query() ->where('tenant_id', $tenantId) ->whereIn('status', [ - Finding::STATUS_ACKNOWLEDGED, Finding::STATUS_TRIAGED, Finding::STATUS_IN_PROGRESS, Finding::STATUS_REOPENED, diff --git a/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php b/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php index 96001021..b19a7e1e 100644 --- a/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php +++ b/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php @@ -99,7 +99,7 @@ private static function resolveTenantWorkspaceId(Model $model, int $tenantId): i $tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null; if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) { - $tenant = Tenant::query()->find($tenantId); + $tenant = Tenant::query()->withTrashed()->find($tenantId); } if (! $tenant instanceof Tenant) { diff --git a/apps/platform/app/Support/Filament/FilterOptionCatalog.php b/apps/platform/app/Support/Filament/FilterOptionCatalog.php index 81b03d6c..fb41668c 100644 --- a/apps/platform/app/Support/Filament/FilterOptionCatalog.php +++ b/apps/platform/app/Support/Filament/FilterOptionCatalog.php @@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array /** * @return array */ - public static function findingStatuses(bool $includeLegacyAcknowledged = true): array + public static function findingStatuses(): array { - $options = self::badgeOptions(BadgeDomain::FindingStatus, [ + return self::badgeOptions(BadgeDomain::FindingStatus, [ Finding::STATUS_NEW, Finding::STATUS_TRIAGED, Finding::STATUS_IN_PROGRESS, @@ -114,21 +114,6 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true): Finding::STATUS_CLOSED, Finding::STATUS_RISK_ACCEPTED, ]); - - if (! $includeLegacyAcknowledged) { - return $options; - } - - return [ - Finding::STATUS_NEW => $options[Finding::STATUS_NEW], - Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED], - Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(), - Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS], - Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED], - Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED], - Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED], - Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED], - ]; } /** @@ -312,11 +297,6 @@ private static function badgeOptions(BadgeDomain $domain, array $values): array ->all(); } - private static function legacyFindingAcknowledgedLabel(): string - { - return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)'; - } - private static function platformLabel(string $platform): string { return match (Str::of($platform) diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index dd5b31ba..5778b251 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -289,27 +289,36 @@ private static function operationAliases(): array return [ new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true), new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'), + new OperationTypeAlias('policy.snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true), + new OperationTypeAlias('policy.restore', 'policy.restore', 'canonical', true), new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'), new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true), new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true), + new OperationTypeAlias('inventory.sync', 'inventory.sync', 'canonical', true), new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'), new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'), new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), + new OperationTypeAlias('directory.groups.sync', 'directory.groups.sync', 'canonical', true), new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true), + new OperationTypeAlias('backup_set.archive', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), + new OperationTypeAlias('backup.schedule.execute', 'backup.schedule.execute', 'canonical', true), new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), + new OperationTypeAlias('backup.schedule.retention', 'backup.schedule.retention', 'canonical', true), new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'), + new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true), new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), + new OperationTypeAlias('directory.role_definitions.sync', 'directory.role_definitions.sync', 'canonical', true), new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'), new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true), new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true), @@ -324,6 +333,7 @@ private static function operationAliases(): array new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true), new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'), new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'), + new OperationTypeAlias('permission.posture.check', 'permission.posture.check', 'canonical', true), new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'), new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true), new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true), diff --git a/apps/platform/database/factories/FindingFactory.php b/apps/platform/database/factories/FindingFactory.php index 1c794878..cb753f6d 100644 --- a/apps/platform/database/factories/FindingFactory.php +++ b/apps/platform/database/factories/FindingFactory.php @@ -73,18 +73,6 @@ public function permissionPosture(): static ]); } - /** - * State for legacy acknowledged findings. - */ - public function acknowledged(): static - { - return $this->state(fn (array $attributes): array => [ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => null, - ]); - } - /** * State for triaged findings. */ diff --git a/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php b/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php index 98c0b406..bddf53c1 100644 --- a/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php +++ b/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php @@ -1,7 +1,10 @@ @php + use App\Support\Verification\VerificationLinkBehavior; + $help = is_array($help ?? null) ? $help : []; $links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : []; $steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : []; + $linkBehavior = app(VerificationLinkBehavior::class); $headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== '' ? (string) ($help['headline']) : 'Contextual help'; @@ -57,9 +60,16 @@
      @foreach ($links as $link) @php + $linkLabel = is_string($link['label'] ?? null) && trim((string) ($link['label'] ?? '')) !== '' + ? (string) $link['label'] + : 'Open'; $linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== '' ? (string) $link['url'] : null; + $behavior = $linkUrl !== null + ? $linkBehavior->describe($linkLabel, $linkUrl) + : null; + $testId = 'contextual-help-link-'.\Illuminate\Support\Str::slug($linkLabel); @endphp @if ($linkUrl) @@ -68,8 +78,11 @@ :href="$linkUrl" size="sm" color="primary" + :target="(bool) ($behavior['opens_in_new_tab'] ?? false) ? '_blank' : null" + :rel="(bool) ($behavior['opens_in_new_tab'] ?? false) ? 'noopener noreferrer' : null" + :data-testid="$testId" > - {{ (string) ($link['label'] ?? 'Open') }} + {{ $linkLabel }} @endif @endforeach diff --git a/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php b/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php index b1072590..6bfea3eb 100644 --- a/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php +++ b/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php @@ -61,18 +61,6 @@ ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); - $visibleSelectValue = <<<'JS' -(() => { - const select = [...document.querySelectorAll('select')].find((element) => { - const style = window.getComputedStyle(element); - - return style.display !== 'none' && style.visibility !== 'hidden'; - }); - - return select?.value ?? null; -})() -JS; - $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page @@ -87,8 +75,8 @@ ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Verify access') ->assertSee('Status: Not started') - ->click('Provider connection') - ->assertScript($visibleSelectValue, (string) $connection->getKey()) + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection') ->click('Create new connection') ->check('internal:label="Dedicated override"s') ->fill('[type="password"]', 'browser-only-secret') @@ -97,8 +85,8 @@ ->waitForText('Status: Not started') ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Verify access') - ->click('Provider connection') - ->assertScript($visibleSelectValue, (string) $connection->getKey()) + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection') ->click('Create new connection') ->check('internal:label="Dedicated override"s') ->assertValue('[type="password"]', ''); diff --git a/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php b/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php index 56bd5cda..b361e5a9 100644 --- a/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php +++ b/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php @@ -86,18 +86,6 @@ ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); - $visibleSelectValue = <<<'JS' -(() => { - const select = [...document.querySelectorAll('select')].find((element) => { - const style = window.getComputedStyle(element); - - return style.display !== 'none' && style.visibility !== 'hidden'; - }); - - return select?.value ?? null; -})() -JS; - $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page @@ -113,8 +101,8 @@ ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Status: Needs attention') ->assertSee('Start verification') - ->click('Provider connection') - ->assertScript($visibleSelectValue, (string) $selectedConnection->getKey()); + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection'); }); it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void { @@ -328,32 +316,14 @@ ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->wait(1) - ->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true) - ->click('[data-testid="verification-assist-trigger"]') - ->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true) - ->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank'); - - $page->script(<<<'JS' -Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { - writeText: async () => Promise.resolve(), - }, -}); - -document.querySelector('[data-testid="verification-assist-copy-application"]')?.click(); -JS); - - $page - ->waitForText('Copied') - ->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer') - ->click('[data-testid="verification-assist-full-page"]') + ->assertScript("document.querySelector('[data-testid=\"contextual-help-link-open-required-permissions\"]') !== null", true) + ->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'target', '_blank') + ->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'rel', 'noopener noreferrer') + ->click('[data-testid="contextual-help-link-open-required-permissions"]') ->wait(1) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) - ->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true) - ->click('Close') - ->click('Provider connection') - ->assertSee('Select an existing connection or create a new one.'); + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection'); }); it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void { diff --git a/apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php b/apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php new file mode 100644 index 00000000..7e41118a --- /dev/null +++ b/apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php @@ -0,0 +1,23 @@ +toBeFalse(); + expect(RoleCapabilityMap::rolesWithCapability('tenant_findings.acknowledge'))->toBe([]); +}); + +it('keeps the canonical findings triage capability available to operators', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + expect(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::TENANT_FINDINGS_TRIAGE))->toBeTrue(); + expect(Gate::forUser($user)->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); +}); diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php index d52d76bc..4f6024ef 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php @@ -9,6 +9,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\OperationRunType; it('writes audit events for baseline capture start and completion with scope + gap summary', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -35,7 +36,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php index 69174e52..e1d2a474 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php @@ -58,7 +58,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php index 42a9c1c5..3dce3a01 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php @@ -12,6 +12,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineSubjectKey; +use App\Support\OperationRunType; it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -73,7 +74,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php index d57d952f..1e504b70 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php @@ -18,6 +18,7 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\PolicyVersionCapturePurpose; +use App\Support\OperationRunType; it('Baseline capture (full content) captures evidence on demand when missing', function () { config()->set('tenantpilot.baselines.full_content_capture.enabled', true); @@ -119,7 +120,7 @@ public function capture( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php index 53844846..b7006446 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php @@ -64,7 +64,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php b/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php index 3e71d06c..4551b9e7 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php @@ -14,6 +14,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineSubjectKey; +use App\Support\OperationRunType; it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -104,7 +105,7 @@ $operationRuns = app(OperationRunService::class); $run = $operationRuns->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php b/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php index be4cc458..6c9f9cc5 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php @@ -17,6 +17,7 @@ use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineSnapshotLifecycleState; use App\Support\Baselines\BaselineSubjectKey; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Queue; function createBaselineCaptureInventoryBasis( @@ -65,7 +66,7 @@ function runBaselineCaptureJob( /** @var OperationRun $run */ $run = $result['run']; - expect($run->type)->toBe('baseline_capture'); + expect($run->type)->toBe(OperationRunType::BaselineCapture->value); expect($run->status)->toBe('queued'); expect($run->tenant_id)->toBe((int) $tenant->getKey()); @@ -104,7 +105,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture when the latest inventory sync was blocked', function () { @@ -135,7 +136,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture when the latest inventory sync failed without falling back to an older success', function () { @@ -166,7 +167,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () { @@ -189,7 +190,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture for a draft profile with reason code', function () { @@ -209,7 +210,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture for an archived profile with reason code', function () { @@ -228,7 +229,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture for a tenant from a different workspace', function () { @@ -274,7 +275,7 @@ function runBaselineCaptureJob( expect($result2['ok'])->toBeTrue(); expect($result1['run']->getKey())->toBe($result2['run']->getKey()); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(1); }); // --- Snapshot dedupe + capture job execution --- @@ -321,7 +322,7 @@ function runBaselineCaptureJob( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -476,7 +477,7 @@ function runBaselineCaptureJob( $run1 = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -499,7 +500,7 @@ function runBaselineCaptureJob( 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'baseline_capture', + 'type' => OperationRunType::BaselineCapture->value, 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp), @@ -586,7 +587,7 @@ function runBaselineCaptureJob( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -662,7 +663,7 @@ function runBaselineCaptureJob( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php b/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php index a91dc7eb..561aaaf2 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php @@ -15,6 +15,7 @@ use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -69,14 +70,14 @@ $activeRuns = OperationRun::query() ->where('workspace_id', (int) $fixture['workspace']->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->get(); expect($activeRuns)->toHaveCount(2) ->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue() - ->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0); + ->and(OperationRun::query()->whereNull('tenant_id')->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void { @@ -97,7 +98,7 @@ expect(OperationRun::query() ->where('workspace_id', (int) $fixture['workspace']->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->whereNull('tenant_id') ->count())->toBe(0); }); diff --git a/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php b/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php index e9a8c70c..bedea333 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -3,6 +3,7 @@ use App\Jobs\EntraGroupSyncJob; use App\Services\Directory\EntraGroupSyncService; use App\Services\Providers\ProviderOperationStartResult; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Queue; it('starts a manual group sync by creating a run and dispatching a job', function () { @@ -21,7 +22,7 @@ expect($run) ->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->user_id)->toBe($user->getKey()) - ->and($run->type)->toBe('entra_group_sync') + ->and($run->type)->toBe(OperationRunType::DirectoryGroupsSync->value) ->and($run->status)->toBe('queued') ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all') ->and($run->context['provider_connection_id'] ?? null)->toBeInt(); diff --git a/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php index eaa512d2..717ca5b3 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -7,6 +7,7 @@ use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\OperationRunType; it('sync job upserts groups and updates run counters', function () { @@ -54,7 +55,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'entra_group_sync', + type: OperationRunType::DirectoryGroupsSync->value, inputs: ['selection_key' => 'groups-v1:all'], initiator: $user, ); diff --git a/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php index ccad36d5..1f7929b6 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -7,6 +7,7 @@ use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Config; it('purges cached groups older than the retention window', function () { @@ -34,7 +35,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'entra_group_sync', + type: OperationRunType::DirectoryGroupsSync->value, inputs: ['selection_key' => 'groups-v1:all'], initiator: $user, ); diff --git a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php index 420acb84..52be5f0e 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php @@ -444,7 +444,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->and($finding->subject_external_id)->toBe('user-1:def-ga'); }); -it('auto-resolve applies to acknowledged findings too', function (): void { +it('auto-resolve applies to triaged findings too', function (): void { [$user, $tenant] = createMinimalUserWithTenant(); $generator = makeGenerator(); @@ -456,20 +456,19 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ); $generator->generate($tenant, $payload); - // Acknowledge the finding + // Triage the finding $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('subject_external_id', 'user-1:def-ga') ->first(); $finding->forceFill([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $user->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'triaged_at' => now(), ])->save(); - expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED); + expect($finding->fresh()->status)->toBe(Finding::STATUS_TRIAGED); - // Scan 2: remove → should auto-resolve even though acknowledged + // Scan 2: remove -> should auto-resolve even though triaged $payload2 = buildPayload([gaRoleDef()], []); $result = $generator->generate($tenant, $payload2); diff --git a/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php index b8b23512..9894f08f 100644 --- a/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php @@ -115,7 +115,7 @@ $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->latest('id') ->first(); @@ -192,7 +192,7 @@ ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void { @@ -250,7 +250,7 @@ ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('can refresh stats without calling mount directly', function (): void { diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php index cb8eb597..d5b01831 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php @@ -9,6 +9,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\OperationRunType; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -121,7 +122,7 @@ function seedCaptureProfileForTenant( $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'baseline_capture') + ->where('type', OperationRunType::BaselineCapture->value) ->latest('id') ->first(); @@ -151,7 +152,7 @@ function seedCaptureProfileForTenant( ->assertStatus(200); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('does not start full-content capture when rollout is disabled', function (): void { @@ -174,7 +175,7 @@ function seedCaptureProfileForTenant( ->assertStatus(200); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void { @@ -228,5 +229,5 @@ function seedCaptureProfileForTenant( ->assertStatus(200); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php index 51430cfa..3dfbda3a 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php @@ -14,6 +14,7 @@ use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\IntuneCompareStrategy; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; +use App\Support\OperationRunType; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -92,7 +93,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->latest('id') ->first(); @@ -120,7 +121,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void { @@ -167,7 +168,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void { @@ -275,5 +276,5 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); diff --git a/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php b/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php index d0489100..967f157a 100644 --- a/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php +++ b/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php @@ -34,7 +34,6 @@ protected function makeFindingForWorkflow(Tenant $tenant, string $status = Findi $factory = Finding::factory()->for($tenant); $factory = match ($status) { - Finding::STATUS_ACKNOWLEDGED => $factory->acknowledged(), Finding::STATUS_TRIAGED => $factory->triaged(), Finding::STATUS_IN_PROGRESS => $factory->inProgress(), Finding::STATUS_REOPENED => $factory->reopened(), diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php index 3575724f..0a70c68a 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php @@ -22,9 +22,8 @@ ->toContain(AuditActionId::FindingReopened->value); }); -it('keeps only legacy compatibility lifecycle helpers on the model', function (): void { - expect(method_exists(Finding::class, 'acknowledge'))->toBeTrue() - ->and(method_exists(Finding::class, 'resolve'))->toBeTrue() +it('keeps only the surviving model lifecycle helpers', function (): void { + expect(method_exists(Finding::class, 'resolve'))->toBeTrue() ->and(method_exists(Finding::class, 'reopen'))->toBeTrue() ->and(method_exists(Finding::class, 'triage'))->toBeFalse() ->and(method_exists(Finding::class, 'startProgress'))->toBeFalse() diff --git a/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php index 6ed71893..1dcac3ff 100644 --- a/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php +++ b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php @@ -101,8 +101,10 @@ function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding 'assignee_user_id' => (int) $otherAssignee->getKey(), ]); - $acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([ + $acknowledged = Finding::factory()->for($tenantA)->create([ 'workspace_id' => (int) $tenantA->workspace_id, + 'status' => 'acknowledged', + 'acknowledged_at' => now(), 'assignee_user_id' => null, 'subject_external_id' => 'acknowledged', ]); diff --git a/apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php b/apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php new file mode 100644 index 00000000..880b4aac --- /dev/null +++ b/apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php @@ -0,0 +1,36 @@ +for($tenant)->create([ + 'status' => 'acknowledged', + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); + + expect(fn () => app(FindingWorkflowService::class)->triage($finding, $tenant, $user)) + ->toThrow(\InvalidArgumentException::class, 'Finding cannot be triaged from the current status.'); +}); + +it('rejects start progress from the removed acknowledged status', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => 'acknowledged', + 'triaged_at' => now()->subMinute(), + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); + + expect(fn () => app(FindingWorkflowService::class)->startProgress($finding, $tenant, $user)) + ->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.'); +}); diff --git a/apps/platform/tests/Feature/Models/FindingResolvedTest.php b/apps/platform/tests/Feature/Models/FindingResolvedTest.php index 8e7da394..b937e49c 100644 --- a/apps/platform/tests/Feature/Models/FindingResolvedTest.php +++ b/apps/platform/tests/Feature/Models/FindingResolvedTest.php @@ -59,15 +59,18 @@ ]); }); -it('supports legacy model helper compatibility for acknowledge', function (): void { +it('keeps stale acknowledged metadata as passive data only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); - $finding = Finding::factory()->for($tenant)->permissionPosture()->create(); + $finding = Finding::factory()->for($tenant)->permissionPosture()->create([ + 'status' => 'acknowledged', + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); - $finding->acknowledge($user); - - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED) + expect($finding->status)->toBe('acknowledged') ->and($finding->acknowledged_at)->not->toBeNull() - ->and($finding->acknowledged_by_user_id)->toBe($user->getKey()); + ->and($finding->acknowledged_by_user_id)->toBe($user->getKey()) + ->and($finding->hasOpenStatus())->toBeFalse(); }); it('exposes v2 open and terminal status helpers', function (): void { @@ -84,31 +87,26 @@ Finding::STATUS_RISK_ACCEPTED, ]); - expect(Finding::openStatusesForQuery())->toContain(Finding::STATUS_ACKNOWLEDGED); + expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses()); }); -it('maps legacy acknowledged status to triaged in v2 helpers', function (): void { - expect(Finding::canonicalizeStatus(Finding::STATUS_ACKNOWLEDGED)) - ->toBe(Finding::STATUS_TRIAGED); +it('does not treat acknowledged as canonical in v2 helpers', function (): void { + expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged'); - expect(Finding::isOpenStatus(Finding::STATUS_ACKNOWLEDGED))->toBeTrue(); - expect(Finding::isTerminalStatus(Finding::STATUS_ACKNOWLEDGED))->toBeFalse(); + expect(Finding::isOpenStatus('acknowledged'))->toBeFalse(); + expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse(); }); -it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void { +it('rejects resolving a stale acknowledged finding', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); - $finding = Finding::factory()->for($tenant)->permissionPosture()->acknowledged()->create([ + $finding = Finding::factory()->for($tenant)->permissionPosture()->create([ + 'status' => 'acknowledged', + 'acknowledged_at' => now(), 'acknowledged_by_user_id' => $user->getKey(), ]); - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED); - - $finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); - - expect($finding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($finding->acknowledged_at)->not->toBeNull() - ->and($finding->acknowledged_by_user_id)->toBe($user->getKey()) - ->and($finding->resolved_at)->not->toBeNull(); + expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED)) + ->toThrow(\InvalidArgumentException::class, 'Only open findings can be resolved.'); }); it('has STATUS_RESOLVED constant', function (): void { diff --git a/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php b/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php index 8e54c8e3..2593dc49 100644 --- a/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php +++ b/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php @@ -14,6 +14,7 @@ use App\Support\Audit\AuditOutcome; use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineReasonCodes; +use App\Support\OperationRunType; it('derives summary-first audit semantics for baseline capture workflow events', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -36,7 +37,7 @@ $operationRunService = app(OperationRunService::class); $run = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -97,7 +98,7 @@ $operationRunService = app(OperationRunService::class); $run = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php index 9e9bd69d..802f74cb 100644 --- a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -26,7 +26,7 @@ ->get('/admin/operations') ->assertOk() ->assertSee($workspaceName ?? 'Select workspace') - ->assertSee('Search tenants…') + ->assertSee(__('localization.shell.search_tenants')) ->assertSee('Switch workspace') ->assertSee('admin/select-tenant') ->assertSee('Clear tenant scope') @@ -66,7 +66,7 @@ ->get('/admin/workspaces') ->assertOk() ->assertSee('Choose a workspace first.') - ->assertDontSee('Search tenants…'); + ->assertDontSee(__('localization.shell.search_tenants')); }); it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void { diff --git a/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php b/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php index 48a0ec19..430de3dd 100644 --- a/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +++ b/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php @@ -93,7 +93,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'tableFilters' => [ 'type' => [ - 'value' => 'inventory_sync', + 'value' => 'inventory.sync', ], ], ])); diff --git a/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php b/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php index 40531580..fd950a36 100644 --- a/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php +++ b/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php @@ -6,6 +6,7 @@ use App\Models\BackupSchedule; use App\Models\BackupSet; use App\Models\OperationRun; +use App\Support\OperationRunType; it('completes backup retention runs without persisting terminal notifications for system runs', function (): void { [$user, $tenant] = createUserWithTenant(role: 'manager'); @@ -62,7 +63,7 @@ $retentionRun = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'backup_schedule_retention') + ->where('type', OperationRunType::BackupScheduleRetention->value) ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php index f40f6285..1d071e97 100644 --- a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php @@ -104,19 +104,17 @@ function errorPermission(string $key, array $features = []): array ->and($finding->resolved_reason)->toBe('permission_granted'); }); -// (3) Auto-resolves acknowledged finding preserving metadata -it('auto-resolves acknowledged finding preserving acknowledged metadata', function (): void { +// (3) Auto-resolves triaged finding preserving triaged metadata +it('auto-resolves triaged finding preserving triaged metadata', function (): void { [$user, $tenant] = createUserWithTenant(); $generator = app(PermissionPostureFindingGenerator::class); $generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); $finding = Finding::query()->where('tenant_id', $tenant->getKey())->first(); - $ackUser = User::factory()->create(); $finding->forceFill([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $ackUser->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'triaged_at' => now(), ])->save(); $result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted')); @@ -124,8 +122,7 @@ function errorPermission(string $key, array $features = []): array $finding->refresh(); expect($result->findingsResolved)->toBe(1) ->and($finding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($finding->acknowledged_at)->not->toBeNull() - ->and($finding->acknowledged_by_user_id)->toBe($ackUser->getKey()); + ->and($finding->triaged_at)->not->toBeNull(); }); // (4) No duplicates on idempotent run diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php index e89d3faf..fe64889f 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php @@ -11,7 +11,7 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php index 6248a0f1..b53abea1 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php @@ -11,7 +11,7 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php index c980ec87..79999812 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php @@ -11,7 +11,7 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php index dbf52092..d1f45122 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php @@ -16,7 +16,7 @@ expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); diff --git a/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php b/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php index aac27f7b..379c29aa 100644 --- a/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php +++ b/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php @@ -24,10 +24,10 @@ ->and($spec->color)->toBe('warning'); }); -it('still renders acknowledged status badge', function (): void { - $spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED); +it('renders unknown for removed acknowledged status badges', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); - expect($spec->label)->toBe('Triaged') + expect($spec->label)->toBe('Unknown') ->and($spec->color)->toBe('gray'); }); diff --git a/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php b/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php index 3c29b46d..7101b43e 100644 --- a/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php +++ b/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php @@ -6,6 +6,7 @@ use App\Services\Providers\ProviderOperationStartResult; use App\Services\Directory\RoleDefinitionsSyncService; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; @@ -36,7 +37,7 @@ $run = $result->run; - expect($run->type)->toBe('directory_role_definitions.sync'); + expect($run->type)->toBe(OperationRunType::DirectoryRoleDefinitionsSync->value); expect($run->context['provider_connection_id'] ?? null)->toBeInt(); $url = OperationRunLinks::tenantlessView($run); diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php index a2eab817..b9bb3adf 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Models\Finding; + it('passes shared canonical control references through tenant review composition', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1); @@ -14,5 +16,30 @@ ->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance') ->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1) ->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance') - ->and($openRisks->summary_payload['canonical_controls'])->toBe([]); + ->and($openRisks->summary_payload['canonical_controls'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance'); +}); + +it('excludes removed acknowledged findings from open risk highlights', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => 'acknowledged', + 'subject_external_id' => 'legacy-acknowledged', + ]); + + $triagedFinding = Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => Finding::STATUS_TRIAGED, + 'subject_external_id' => 'canonical-triaged', + ]); + + $snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $openRisks = $review->sections->firstWhere('section_key', 'open_risks'); + $entries = $openRisks->render_payload['entries'] ?? []; + + expect($entries)->toHaveCount(1) + ->and($entries[0]['id'] ?? null)->toBe((int) $triagedFinding->getKey()) + ->and(collect($entries)->pluck('status')->all())->not->toContain('acknowledged'); }); diff --git a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php index a1037a71..5deb2125 100644 --- a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php +++ b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php @@ -23,7 +23,7 @@ ->assertSee('Tenant Panel Entry') ->assertSee('Switch tenant') ->assertSee('Clear tenant scope') - ->assertDontSee('Search tenants…') + ->assertDontSee(__('localization.shell.search_tenants')) ->assertDontSee('admin/select-tenant'); }); diff --git a/apps/platform/tests/Unit/Badges/FindingBadgesTest.php b/apps/platform/tests/Unit/Badges/FindingBadgesTest.php index 2d3b8b9a..ba42bcb8 100644 --- a/apps/platform/tests/Unit/Badges/FindingBadgesTest.php +++ b/apps/platform/tests/Unit/Badges/FindingBadgesTest.php @@ -33,7 +33,7 @@ expect($triaged->color)->toBe('gray'); $legacyAcknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); - expect($legacyAcknowledged->label)->toBe('Triaged'); + expect($legacyAcknowledged->label)->toBe('Unknown'); expect($legacyAcknowledged->color)->toBe('gray'); $inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'in_progress'); diff --git a/apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php b/apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php new file mode 100644 index 00000000..1c3e3b1a --- /dev/null +++ b/apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php @@ -0,0 +1,22 @@ +toBe([ + Finding::STATUS_NEW, + Finding::STATUS_TRIAGED, + Finding::STATUS_IN_PROGRESS, + Finding::STATUS_REOPENED, + ]); + + expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses()); +}); + +it('does not treat acknowledged as a canonical open or terminal status', function (): void { + expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged'); + expect(Finding::isOpenStatus('acknowledged'))->toBeFalse(); + expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php index 13600444..08b5bd98 100644 --- a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php +++ b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php @@ -9,15 +9,14 @@ $types = app(OperationLifecyclePolicy::class)->coveredTypeNames(); expect($types)->toBe([ - 'baseline_capture', - 'baseline_compare', - 'inventory_sync', + 'baseline.capture', + 'baseline.compare', + 'inventory.sync', 'policy.sync', - 'policy.sync_one', - 'entra_group_sync', - 'directory_role_definitions.sync', + 'directory.groups.sync', + 'directory.role_definitions.sync', 'backup_set.update', - 'backup_schedule_run', + 'backup.schedule.execute', 'restore.execute', 'tenant.review_pack.generate', 'tenant.review.compose', @@ -28,19 +27,19 @@ it('requires direct failed-job bridges for lifecycle policy entries that declare them', function (): void { $validator = app(OperationLifecyclePolicyValidator::class); - expect($validator->jobUsesDirectFailedBridge('baseline_capture'))->toBeTrue() - ->and($validator->jobUsesDirectFailedBridge('baseline_compare'))->toBeTrue() - ->and($validator->jobUsesDirectFailedBridge('inventory_sync'))->toBeTrue() + expect($validator->jobUsesDirectFailedBridge('baseline.capture'))->toBeTrue() + ->and($validator->jobUsesDirectFailedBridge('baseline.compare'))->toBeTrue() + ->and($validator->jobUsesDirectFailedBridge('inventory.sync'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('policy.sync'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('tenant.review.compose'))->toBeTrue() - ->and($validator->jobUsesDirectFailedBridge('backup_schedule_run'))->toBeFalse(); + ->and($validator->jobUsesDirectFailedBridge('backup.schedule.execute'))->toBeFalse(); }); it('requires explicit timeout and fail-on-timeout declarations for covered jobs', function (): void { $validator = app(OperationLifecyclePolicyValidator::class); - expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300) - ->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue() + expect($validator->jobTimeoutSeconds('baseline.capture'))->toBe(300) + ->and($validator->jobFailsOnTimeout('baseline.capture'))->toBeTrue() ->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240) ->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue() ->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420) diff --git a/apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php b/apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php new file mode 100644 index 00000000..f172bc28 --- /dev/null +++ b/apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php @@ -0,0 +1,22 @@ +toBe([ + Finding::STATUS_NEW => 'New', + Finding::STATUS_TRIAGED => 'Triaged', + Finding::STATUS_IN_PROGRESS => 'In progress', + Finding::STATUS_REOPENED => 'Reopened', + Finding::STATUS_RESOLVED => 'Resolved', + Finding::STATUS_CLOSED => 'Closed', + Finding::STATUS_RISK_ACCEPTED => 'Risk accepted', + ]); +}); + +it('does not offer acknowledged as a legacy findings filter option', function (): void { + expect(FilterOptionCatalog::findingStatuses())->not->toHaveKey('acknowledged'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php index 938561ac..68a556de 100644 --- a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -15,6 +15,7 @@ use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewFingerprint; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -64,6 +65,7 @@ OperationRun::factory() ->forTenant($bravoTenant) ->create([ + 'type' => OperationRunType::InventorySync->value, 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(6), diff --git a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php index a77ee8cf..2ac6efcb 100644 --- a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php +++ b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php @@ -53,6 +53,29 @@ ]); }); +it('records a tenant-owned usage event for an archived tenant', function () { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->for($workspace)->archived()->create(); + $user = User::factory()->create(); + + $event = app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant_onboarding_session', + subjectId: 99, + metadata: [ + 'checkpoint_key' => 'verify_access', + 'lifecycle_state' => 'draft', + ], + ); + + expect($event->workspace_id)->toBe((int) $workspace->getKey()) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->feature_area)->toBe('onboarding'); +}); + it('rejects unknown event names before writing telemetry rows', function () { $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->for($workspace)->create(); diff --git a/specs/254-remove-acknowledged-compat/checklists/requirements.md b/specs/254-remove-acknowledged-compat/checklists/requirements.md new file mode 100644 index 00000000..8b520791 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Remove Legacy Acknowledged Finding Status Compatibility + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-29 +**Feature**: specs/254-remove-acknowledged-compat/spec.md + +## Content Quality + +- [x] No language/framework/API design leakage; concrete repo surfaces, status constants, capability keys, and shared helpers are named only because this cleanup removes those exact repo-visible compatibility seams. +- [x] Focused on user value and business needs +- [x] Written primarily for product and review stakeholders, with bounded repo-specific terminology only where the cleanup target would otherwise stay ambiguous +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria stay outcome-oriented, with bounded repo-specific seams named only where they are required to define the cleanup target +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature defines measurable outcomes in Success Criteria and maps them to explicit proof tasks for implementation-time validation +- [x] No unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible compatibility seams + +## Test Governance Review + +- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus bounded `heavy-governance` guard coverage so shared status-badge and filter drift cannot silently reintroduce acknowledged semantics. +- [x] No new browser or heavy-governance family is introduced; retained guard coverage stays explicit and limited to shared findings status seams. +- [x] Suite-cost outcome is net-neutral to slightly negative: acknowledged-only compatibility expectations should be consolidated or deleted rather than widening shared defaults. + +## Review Outcome + +- [x] Review outcome class: `acceptable-special-case` +- [x] Workflow outcome: `keep` +- [x] Review-note location is explicit: the guardrail and lane-fit notes live in `spec.md`, the checklist, and the final preparation report. + +## Notes + +- The spec intentionally names concrete findings status constants, capability aliases, shared catalogs, and summary builders because the product value of this slice is removing those exact compatibility seams from repo truth. +- Verification-check acknowledgement and onboarding acknowledgement remain explicit non-goals so the cleanup cannot expand into a broader terminology rewrite. +- Validation pass complete: no clarification markers remain, the slice stays LEAN-001-compliant, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml b/specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml new file mode 100644 index 00000000..b34c02b0 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml @@ -0,0 +1,121 @@ +version: 1 +kind: findings-acknowledged-compat-removal + +scope: + goal: remove productive acknowledged compatibility from findings workflow truth only + non_goals: + - findings lifecycle backfill runtime-surface removal + - creation-time finding invariant hardening + - broader findings lifecycle redesign + - verification acknowledgement cleanup + - onboarding acknowledgement cleanup + - restore impact acknowledgement cleanup + - migration or fallback-reader preservation + +canonical_status_contract: + active_open: + - new + - triaged + - in_progress + - reopened + terminal: + - resolved + - closed + - risk_accepted + removed_active_status: + - acknowledged + +shared_seams: + model_and_workflow: + owner_files: + - apps/platform/app/Models/Finding.php + - apps/platform/app/Services/Findings/FindingWorkflowService.php + - apps/platform/app/Policies/FindingPolicy.php + requirements: + - no productive findings workflow helper writes or expects acknowledged + - open-status query helpers collapse onto the canonical active-open set only + badge_and_filter_catalogs: + owner_files: + - apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php + - apps/platform/app/Support/Filament/FilterOptionCatalog.php + requirements: + - no badge label exposes acknowledged or legacy acknowledged + - no findings filter offers acknowledged as a current workflow state + capabilities_and_roles: + owner_files: + - apps/platform/app/Support/Auth/Capabilities.php + - apps/platform/app/Services/Auth/RoleCapabilityMap.php + requirements: + - tenant_findings.acknowledge is removed + - surviving findings capability language stays canonical and tenant-scoped + tenant_findings_surfaces: + routes: + - /admin/t/{tenant}/findings + - /admin/t/{tenant}/findings/{record} + owner_files: + - apps/platform/app/Filament/Resources/FindingResource.php + - apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + - apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php + requirements: + - no visible findings workflow affordance presents acknowledged as current work + findings_derived_consumers: + owner_files: + - apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php + - apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php + - apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php + - apps/platform/app/Support/Baselines/BaselineCompareStats.php + - apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php + - apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php + - apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php + - apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + - apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + - apps/platform/app/Services/Baselines/BaselineAutoCloseService.php + - apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php + requirements: + - counts, previews, review disclosures, diagnostics, and alerts use the same canonical open-status set as findings surfaces + - no productive findings-derived consumer treats acknowledged as current work + +retained_behavior: + findings_workflow_actions: + - triage + - start_progress + - assign + - resolve + - close + - reopen + - request_exception + - risk_accept + guarantees: + - existing findings lifecycle outcomes remain otherwise unchanged + - no new workflow state or replacement compatibility path is introduced + +non_finding_domains: + untouched: + - verification check acknowledgement + - onboarding verification acknowledgement + - restore impact acknowledgement + +legacy_data_posture: + findings_table: + - acknowledged columns may remain in schema for now without preserving active runtime semantics + migrations: + - no new migration or persisted compatibility artifact is allowed in this slice + +validation_expectations: + no_new_persistence: + - no file under apps/platform/database/migrations may change + - no alias table, persisted mapping, or fallback reader may be introduced + absence_proof: + - no productive findings surface exposes acknowledged as current workflow status + - no productive findings-derived consumer exposes acknowledged as current work + - no findings capability alias remains for acknowledge semantics + regression_proof: + - canonical findings workflow actions still behave unchanged + - non-finding acknowledgement domains remain untouched + lane_classification: + required: + - fast-feedback + - confidence + - heavy-governance + excluded: + - browser \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/data-model.md b/specs/254-remove-acknowledged-compat/data-model.md new file mode 100644 index 00000000..0160f0c1 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/data-model.md @@ -0,0 +1,103 @@ +# Data Model — Remove Legacy Acknowledged Finding Status Compatibility + +**Spec**: [spec.md](spec.md) + +This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one legacy findings workflow branch from productive code and the reaffirmation of the canonical findings lifecycle as the only active status contract. + +## Existing Canonical Entities Reused + +### Finding (`findings`) + +**Purpose**: Tenant-owned findings workflow truth. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `triaged_at` +- `in_progress_at` +- `reopened_at` +- `resolved_at` +- `closed_at` +- `risk_accepted_at` via related exception state where applicable +- `first_seen_at` +- `last_seen_at` +- `times_seen` +- `sla_days` +- `due_at` +- `acknowledged_at` +- `acknowledged_by_user_id` + +**Feature use**: +- Remains the single canonical workflow truth for findings. +- Continues to require both `workspace_id` and `tenant_id` as ownership anchors. +- Keeps the surviving active status contract: `new`, `triaged`, `in_progress`, `reopened`. +- Keeps the surviving terminal status contract: `resolved`, `closed`, `risk_accepted`. +- `acknowledged_at` and `acknowledged_by_user_id` may remain in schema for now, but they no longer justify an active workflow status, query branch, or UI affordance. + +### FindingException (`finding_exceptions`) + +**Purpose**: Existing risk-acceptance and exception truth attached to findings. + +**Feature use**: +- Remains unchanged. +- Exists only for regression protection so removing `acknowledged` does not collapse or rename risk-governance semantics. + +## Removed Active Workflow Contract + +### LegacyAcknowledgedFindingStatus (removed, non-persisted contract) + +**Previous role**: +- active status constant on `Finding` +- extra member of `openStatusesForQuery()` +- special-case filter and badge label +- capability alias and RBAC wording branch +- compatibility expectation in findings-facing tests and summary consumers + +**Removal rule**: +- no productive code path writes `acknowledged` as current findings status +- no productive code path queries `acknowledged` as part of the active open-status set +- no productive findings UI or summary consumer presents `acknowledged` as current work +- no role or capability mapping preserves `tenant_findings.acknowledge` + +## Derived Non-Persisted Contracts + +### CanonicalFindingOpenStatusSet (derived) + +**Members**: +- `new` +- `triaged` +- `in_progress` +- `reopened` + +**Consumers**: +- findings resource and inbox queries +- workspace overview and governance inbox summaries +- review/report disclosure helpers that describe current open findings work +- support-diagnostic bundles that group active findings issues +- alerts, hygiene services, and findings generators that still look up active/open findings + +### CanonicalFindingWorkflowPermissionSet (derived) + +**Purpose**: Surviving capability vocabulary for findings workflow actions. + +**Feature use**: +- remove `tenant_findings.acknowledge` +- keep surviving findings permissions and policy checks authoritative +- keep `404` versus `403` semantics unchanged for tenant-scoped findings surfaces + +## Data Ownership Notes + +- No new table, column, persisted alias, cache, or compatibility projection is introduced. +- No migration or historical data rewrite is planned. +- Review/report and support-diagnostic consumers remain derived over tenant-owned findings truth; they do not become separate persisted status stores. +- Verification-check acknowledgement, onboarding acknowledgement, and restore acknowledgement remain separate domains and are not remodeled here. + +## Removal Invariants + +- No productive code path may treat `acknowledged` as a current findings workflow status. +- No productive query helper may include `acknowledged` in the active open findings set. +- No shared badge, filter, summary, review/report disclosure, or support-diagnostic grouping may present `acknowledged` as current findings work. +- No new migration or persisted compatibility artifact may be introduced to preserve the removed branch. +- No non-finding acknowledgement domain may change as collateral damage from this cleanup. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/plan.md b/specs/254-remove-acknowledged-compat/plan.md new file mode 100644 index 00000000..17e80148 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/plan.md @@ -0,0 +1,266 @@ +# Implementation Plan: Remove Legacy Acknowledged Finding Status Compatibility + +**Branch**: `254-remove-acknowledged-compat` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/254-remove-acknowledged-compat/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/254-remove-acknowledged-compat/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Remove productive `acknowledged` compatibility from the findings domain by collapsing canonical status, query, badge, filter, capability, policy, and workflow seams onto the surviving findings lifecycle only. +- Keep the slice subtractive and repo-based: no new state, no migration shim, no repair tooling, no broader lifecycle redesign, and no changes to verification-check or onboarding acknowledgement domains. +- Validate the cleanup through focused workflow, summary, badge or filter, capability, and guard coverage so shared findings-derived counts and operator surfaces converge on one canonical language at the same time. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings workflow services, shared badge and filter catalogs, capability registry, and canonical summary builders +**Storage**: PostgreSQL existing `findings`, `finding_exceptions`, `audit_logs`, `operation_runs`, and related read models only; no new persistence or migration is planned +**Testing**: Pest unit, feature, and bounded heavy-governance guard coverage +**Validation Lanes**: fast-feedback, confidence, heavy-governance +**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and canonical `/admin` summary or inbox surfaces +**Project Type**: web +**Performance Goals**: shared query helpers, inboxes, and summary builders keep their current bounded DB-only render profile; the slice should reduce branching and suite noise rather than add overhead +**Constraints**: LEAN-001 replacement over shims; no schema drop by default; preserve current `404` versus `403` isolation semantics; no panel or provider changes; no new assets; no widening into creation-time invariant hardening, backfill-runtime-surface work, or external support handoff +**Scale/Scope**: 1 cleanup slice touching the `Finding` model and factory, findings workflow service and policy seam, shared badge and filter catalog paths, findings resource and inbox surfaces, canonical summary builders, capability and role maps, and the related findings and guard tests + +## Likely Affected Repo Surfaces + +- Canonical findings status and workflow seams: `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Policies/FindingPolicy.php`, and `apps/platform/database/factories/FindingFactory.php` +- Shared operator vocabulary seams: `apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Support/Auth/Capabilities.php`, and `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- Tenant findings Filament surfaces: `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and related workflow concerns +- Findings-derived summary and review helpers still relying on shared open-status handling or explicit `acknowledged` strings: `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` +- Query and generator consumers of `Finding::openStatusesForQuery()`: `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` +- Current proof surface likely requiring update or replacement: `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +## Domain / Model Fit + +- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, enum family, compatibility table, or derived persistence layer is introduced. +- The canonical active lifecycle stays `new`, `triaged`, `in_progress`, and `reopened`, with `resolved`, `closed`, and `risk_accepted` remaining the canonical terminal set. `acknowledged` is removed as an active status contract rather than remapped through runtime helpers. +- `openStatusesForQuery()` and related status helpers should collapse onto the canonical open-status set instead of preserving an extra compatibility branch for `acknowledged`. +- Existing `acknowledged_at` and `acknowledged_by_user_id` columns are not justification for compatibility behavior in this slice. Default plan posture is to leave schema shape unchanged and remove productive semantics only. +- Legacy factory or fixture helpers that create `acknowledged` findings should be deleted or confined to explicitly documented stale-data edge proof only if implementation later proves that is still needed. They should not remain the default way to express current workflow truth. + +## UI / Filament & Livewire Fit + +- All touched operator surfaces remain native Filament v5 on Livewire v4. No custom dashboard framework, no panel change, and no new provider registration work are needed. +- `FindingResource` already has a `view` page, so the feature does not create a Filament global-search compliance problem. No new searchable resource is introduced. +- Shared badge rendering stays on `BadgeCatalog` plus `BadgeRenderer`, and shared filter vocabulary stays on `FilterOptionCatalog`; the cleanup must remove `acknowledged` from those shared paths rather than introducing page-local label overrides. +- Findings table, detail, and inbox surfaces should remove `acknowledged` from triage or progress visibility checks, filter options, summary counts, and helper wording together so operator UI does not drift between list, detail, and summary shells. +- No new destructive action is added. Existing destructive-like finding actions remain out of scope except that any touched action surface must preserve current `->requiresConfirmation()` and server-side capability or policy enforcement. +- No panel-only or shared asset changes are planned, so deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets. + +## RBAC / Policy Fit + +- Tenant membership and workspace membership remain the isolation boundaries: non-members stay `404`, entitled members missing the surviving capability stay `403`, and no resource existence leak is introduced while cleaning status language. +- `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE` is removed instead of preserved as an alias. `RoleCapabilityMap`, `FindingPolicy`, and `FindingWorkflowService` should converge on the surviving findings capabilities only. +- The feature does not add a new role, a new authorization plane, or any page-local permission dialect. It narrows existing capability vocabulary. +- Disabled helper text and action affordances should continue to rely on the existing shared UI enforcement path while referencing only canonical findings workflow permissions. + +## Audit / Logging Fit + +- No new `AuditActionId` is introduced. +- Existing findings workflow audit verbs such as triage, assign, start progress, resolve, close, reopen, and risk acceptance remain canonical. The cleanup should not revive or add a finding-acknowledged audit dialect. +- Historical pre-production audit or metadata values do not justify runtime label shims. Verification-check acknowledgement audit behavior remains untouched. + +## Migration / Data Shape Fit + +- No new migration, no historical data backfill, and no fallback reader are planned. +- Repo evidence shows the findings status is a string column and the `acknowledged` behavior lives in code, factories, and tests rather than in a database enum or required migration path. +- Default implementation posture is to leave `findings` table columns intact for now and remove productive status compatibility from code and fixtures only. If schema removal appears necessary later, that must be split or re-justified instead of silently widening this cleanup. +- Local pre-production rows that still contain `acknowledged` are not a product compatibility requirement. Dev data reset or fixture replacement is preferred over runtime support. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament plus shared badge, filter, and summary primitives +- **Shared-family relevance**: status messaging, findings workflow actions, shared badge semantics, shared filter vocabularies, canonical summary counts and previews +- **State layers in scope**: page, detail, URL-query +- **Audience modes in scope**: operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third +- **Raw/support gating plan**: existing capability-gated diagnostics remain unchanged; no new raw or support surface is introduced +- **One-primary-action / duplicate-truth control**: findings surfaces keep one canonical workflow language so triage and progress remain the dominant next actions without a duplicate `acknowledged` branch competing in badges, filters, or summaries +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now; any leftover productive `acknowledged` findings seam after implementation is a blocker +- **Special surface test profiles**: standard-native-filament, global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; the removal must happen in the shared seams themselves rather than through local exemptions +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `Finding`, `FindingWorkflowService`, `FindingPolicy`, `FindingStatusBadge`, `FilterOptionCatalog`, `Capabilities`, `RoleCapabilityMap`, `FindingResource`, `ListFindings`, `MyFindingsInbox`, `WorkspaceHealthSummaryQuery`, `WorkspaceOverviewBuilder`, `GovernanceInboxSectionBuilder`, `BaselineCompareStats`, `TenantReviewSectionFactory`, and the generator or alert consumers of shared open-status helpers +- **Shared abstractions reused**: `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, shared findings status helpers, `UiEnforcement`, and the canonical capability registry +- **New abstraction introduced? why?**: none; the correct move is to remove one legacy branch from existing shared seams +- **Why the existing abstraction was sufficient or insufficient**: the shared seams are sufficient once the compatibility branch is removed centrally; they are the reason the cleanup cannot stay local to one page or test file +- **Bounded deviation / spread control**: none; any repo surface still naming productive findings `acknowledged` after the cleanup is drift and should be removed rather than wrapped + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: existing findings and summary surfaces only; no `OperationRun` start or link semantics change +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing findings, review, and governance vocabulary only +- **Neutral platform terms / contracts preserved**: existing platform and findings vocabulary remains; the cleanup narrows a finding-domain alias rather than spreading provider language +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshots-second: PASS - findings remain the last-observed tenant truth, and no backup or snapshot contract changes are introduced +- Read/write separation: PASS - the slice does not add a new write path; it only narrows status semantics on existing findings workflows +- Graph contract path: PASS - no Microsoft Graph contract or provider endpoint change is involved +- Deterministic capabilities: PASS - the capability registry becomes simpler by removing a stale alias instead of expanding capability derivation +- RBAC-UX and isolation: PASS - `/admin/t/{tenant}` findings surfaces remain tenant-scoped; non-members stay `404`; in-scope members missing surviving findings capability stay `403`; no raw capability strings should survive after cleanup +- Workspace isolation / tenant isolation: PASS - tenant-owned findings and derived canonical summaries keep current workspace plus tenant entitlement rules +- Destructive confirmation standard: PASS - no new destructive action is introduced; any touched destructive-like findings action must preserve current confirmation and authorization semantics +- Global search safety: PASS - `FindingResource` already has a view page, and no new searchable resource is added +- OperationRun observability and Ops-UX: PASS - no new operation type, run start surface, or run-notification path is introduced +- Data minimization: PASS - no new payload, no raw evidence expansion, and no new audit family is introduced +- Test governance (`TEST-GOV-001`): PASS - proof stays in focused unit plus feature coverage with one explicit retained heavy-governance guard layer for shared badge or filter drift +- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the plan is subtractive and introduces no new abstraction, registry, or semantic framework +- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or stored compatibility layer is added +- Behavioral state (`STATE-001`): PASS - the feature removes a legacy active-workflow branch instead of adding a new state family +- UI semantics (`UI-SEM-001`) and shared pattern first (`XCUT-001`): PASS - badge, filter, workflow, and summary semantics stay on existing shared seams rather than a new interpretation layer +- Provider boundary (`PROV-001`) and few layers (`V1-EXP-001`, `LAYER-001`): PASS - no provider seam or extra layer is introduced +- Filament-native UI and planning contract: PASS - Filament v5 remains on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no panel or asset strategy change is required +- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for findings status helpers, badge or filter catalog semantics, and capability-registry cleanup; Feature for findings workflow actions, resource or inbox behavior, policy outcomes, and shared summary consumers; Heavy-Governance for the explicit guard layer that blocks ad-hoc status badge or table drift +- **Affected validation lanes**: fast-feedback, confidence, heavy-governance +- **Why this lane mix is the narrowest sufficient proof**: the core risk is central semantic drift across shared helpers and operator surfaces, not browser choreography or new async behavior. Focused unit and feature coverage prove the canonical status path, while one retained guard layer ensures `acknowledged` does not reappear through shared UI seams +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` +- **Fixture / helper / factory / seed / context cost risks**: acknowledged-specific fixtures and factory states are likely removal targets; keep any remaining stale-data setup explicit and local instead of spreading a legacy default across helper layers +- **Expensive defaults or shared helper growth introduced?**: no; expected net-neutral to negative because the slice removes compatibility branches and stale test setup +- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is planned; one existing shared guard layer remains explicit because badge and filter drift can otherwise reintroduce removed semantics silently +- **Surface-class relief / special coverage rule**: standard-native-filament and global-context-shell relief are sufficient; no browser lane is required for this cleanup +- **Closing validation and reviewer handoff**: rerun the focused commands above, verify that no productive findings seam, capability alias, badge label, filter option, or summary builder still treats `acknowledged` as current workflow truth, and verify that verification or onboarding acknowledgement domains remain unchanged +- **Budget / baseline / trend follow-up**: expected net-neutral to slightly negative because compatibility-only tests should be removed or consolidated +- **Review-stop questions**: did implementation leave `acknowledged` in a shared status helper, a policy or capability path, a badge or filter catalog, a summary builder, or a generator test family; did it widen into schema removal or non-finding acknowledgement work +- **Escalation path**: reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: this slice is a bounded cleanup; creation-time invariant hardening, backfill-runtime-surface removal, and external support handoff already remain explicit separate follow-up work + +## Rollout & Risk Controls + +- Implement as replacement, not aliasing. Shared helpers, capability registries, and summary builders should converge directly on canonical statuses in the same slice. +- Treat local stale `acknowledged` rows as pre-production cleanup debt, not a customer compatibility contract. Do not add fallback readers or UI labels to preserve them. +- Preserve scope boundaries aggressively: verification acknowledgement, onboarding verification acknowledgement, restore impact acknowledgement, and non-finding support acknowledgement semantics stay untouched. +- Review stop conditions should fire if implementation tries to drop schema, invent a new compatibility mapper, or widen into findings lifecycle redesign. +- Rollout is code-only and repo-local. No queue, deployment, asset, or migration sequencing is expected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/254-remove-acknowledged-compat/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── findings-acknowledged-compat-removal.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/Findings/ +│ │ └── Resources/FindingResource/ +│ ├── Jobs/Alerts/ +│ ├── Models/ +│ ├── Policies/ +│ ├── Services/ +│ │ ├── Auth/ +│ │ ├── Baselines/ +│ │ ├── EntraAdminRoles/ +│ │ ├── Findings/ +│ │ ├── PermissionPosture/ +│ │ └── TenantReviews/ +│ └── Support/ +│ ├── Auth/ +│ ├── Badges/ +│ ├── CustomerHealth/ +│ ├── Filament/ +│ ├── GovernanceInbox/ +│ └── Workspaces/ +├── database/ +│ └── factories/ +└── tests/ + ├── Feature/ + │ ├── Auth/ + │ ├── Findings/ + │ ├── Guards/ + │ ├── PermissionPosture/ + │ └── EntraAdminRoles/ + └── Unit/ + ├── Badges/ + └── Findings/ +``` + +**Structure Decision**: Laravel monolith. Implementation should stay inside existing findings model, workflow, policy, shared support, Filament resource or page, factory, and test directories rather than creating a new namespace or migration track. + +## Complexity Tracking + +No constitution violation is expected. If implementation later proves it needs schema removal, a compatibility shim, or a new translation layer, that is a stop condition and should be split or rejected rather than absorbed here. + +## Proportionality Review + +N/A - this slice removes a legacy active-workflow alias and a stale capability alias. It introduces no new enum, presenter, persistence, contract layer, or taxonomy. + +## Phase 0 — Research (output: `research.md`) + +- Confirm the exact productive findings seams that still use `acknowledged` and separate them from explicitly out-of-scope non-finding acknowledgement domains. +- Confirm the no-migration posture for existing `acknowledged_*` fields and local stale rows under LEAN-001. +- Confirm which summary or review helpers still encode literal `acknowledged` status expectations and therefore belong in this cleanup instead of a later lifecycle redesign. +- Confirm which existing tests should be deleted, narrowed, or rewritten rather than preserved as compatibility proof. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +- `data-model.md` should describe the surviving canonical findings status set, the collapsed open-status query contract, the removal of the acknowledge capability alias, and the unchanged schema posture. +- `contracts/findings-acknowledged-compat-removal.contract.yaml` should capture the cleanup matrix across model helpers, workflow and policy authorization, shared badge or filter catalogs, findings resource behavior, summary consumers, and out-of-scope acknowledgement domains. +- `quickstart.md` should document the intended implementation order, validation commands, and review stop conditions for scope drift. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Collapse `Finding` status helpers and legacy model helpers onto the canonical status set only. +- Remove `TENANT_FINDINGS_ACKNOWLEDGE` and update role mappings, workflow authorization, and policy checks to the surviving capability set. +- Remove `acknowledged` from shared badge and filter catalogs and from findings resource or inbox workflow affordances. +- Update shared summary and generator consumers of `openStatusesForQuery()` or explicit `acknowledged` status lists so counts, previews, and auto-close behavior align with canonical statuses. +- Delete or rewrite acknowledged-compatibility tests and factories, then add focused regression proof for the surviving canonical workflow, summary alignment, and guard coverage. +- Verify that no non-finding acknowledgement domains were touched and that no migration or compatibility shim was introduced. + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape must stay subtractive, keep Filament v5 on Livewire v4, leave provider registration unchanged in `apps/platform/bootstrap/providers.php`, keep `FindingResource` global-search-safe through its existing view page, add no new destructive action or asset bundle, and preserve the no-migration, no-compatibility-shim posture. diff --git a/specs/254-remove-acknowledged-compat/quickstart.md b/specs/254-remove-acknowledged-compat/quickstart.md new file mode 100644 index 00000000..f3ca9cb1 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart — Remove Legacy Acknowledged Finding Status Compatibility + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing findings, RBAC, summary, and generator test fixtures available +- Existing seeded tenant/workspace context for targeted findings workflow tests + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Run targeted tests after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Alerts/SlaDueAlertTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` +- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm the findings register and detail use canonical status badges, filters, helper text, and workflow wording only. +2. Exercise canonical findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Risk accept` and confirm no action or helper text refers to an acknowledge alias. +3. Open the affected canonical `/admin` summary and inbox surfaces and confirm counts and previews match the same canonical open findings set as the findings register. +4. Open an in-scope tenant review, review-pack, or support-diagnostic surface that renders findings-derived open-work disclosure and confirm it does not describe `acknowledged` as current work. +5. Verify capability-driven findings gating no longer references `tenant_findings.acknowledge` while preserving existing `404` versus `403` behavior. +6. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new persisted compatibility artifact was introduced. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament resources, pages, and shared support helpers. +- No panel or provider registration changes are planned; `apps/platform/bootstrap/providers.php` remains authoritative if provider work is ever needed later. +- `FindingResource` already has a view page, so the feature does not create a global-search contract issue. +- No asset changes are expected, so there is no additional `filament:assets` deployment work for this slice. +- LEAN-001 applies directly: remove compatibility branches instead of preserving aliases, fallback readers, or migrations for historical pre-production rows. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/research.md b/specs/254-remove-acknowledged-compat/research.md new file mode 100644 index 00000000..4a1d0d18 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/research.md @@ -0,0 +1,129 @@ +# Research — Remove Legacy Acknowledged Finding Status Compatibility + +**Date**: 2026-04-29 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded planning decisions for the acknowledged-compatibility cleanup slice. All decisions assume the current pre-production LEAN-001 posture. + +## Decision 1 — Remove acknowledged semantics at shared seams, not through page-local relabeling + +**Decision**: Delete productive `acknowledged` compatibility from the shared findings seams that currently define status truth, query truth, badge vocabulary, filter vocabulary, workflow eligibility, and capability language. Do not treat this as a page-local label replacement. + +**Rationale**: +- The drift is cross-surface today: `Finding::STATUS_ACKNOWLEDGED`, `Finding::openStatusesForQuery()`, `FilterOptionCatalog`, `BadgeCatalog`, `FindingWorkflowService`, `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE`, and multiple summary consumers all preserve the old vocabulary. +- A list-only or badge-only rename would leave summary counts, disabled helper text, and RBAC wording inconsistent. +- XCUT-001 requires converging on the existing shared path instead of adding local exceptions. + +**Evidence**: +- `apps/platform/app/Models/Finding.php` +- `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- `apps/platform/app/Support/Filament/FilterOptionCatalog.php` +- `apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php` +- `apps/platform/app/Support/Auth/Capabilities.php` +- `apps/platform/app/Services/Auth/RoleCapabilityMap.php` + +**Alternatives considered**: +- Relabel `acknowledged` to `triaged` only on findings pages. + - Rejected: shared queries, summaries, and capability guidance would still preserve conflicting truth. +- Keep a read-side compatibility mapper indefinitely. + - Rejected: LEAN-001 forbids preserving a pre-production legacy branch without a current-release need. + +## Decision 2 — Treat stale acknowledged rows and columns as pre-production residue, not as a runtime compatibility contract + +**Decision**: Keep the cleanup code-only by default. Remove productive semantics first and do not add migration shims, fallback readers, or preserved UI labels just because `acknowledged_at` or `acknowledged_by_user_id` columns still exist locally. + +**Rationale**: +- The repo is explicitly pre-production, and LEAN-001 prefers replacement or deletion over historical compatibility behavior. +- The current problem is active workflow semantics in code and tests, not an unavoidable database constraint. +- The narrowest correct implementation is to stop writing, querying, and presenting `acknowledged` as current findings truth. + +**Evidence**: +- `apps/platform/app/Models/Finding.php` +- `.specify/memory/constitution.md` (LEAN-001, PERSIST-001) +- `docs/product/spec-candidates.md` + +**Alternatives considered**: +- Add a migration or fallback reader now. + - Rejected: widens scope into persistence work not justified by current release truth. +- Preserve `legacy acknowledged` UI labels until later. + - Rejected: keeps the removed semantics productized. + +## Decision 3 — Keep findings-derived review, report, and support-diagnostic consumers in scope where they surface current open-work truth + +**Decision**: Include review/report and support-diagnostic consumers in this cleanup only where they derive current findings-open counts, disclosure text, or issue grouping from the same shared status helpers as canonical summaries. + +**Rationale**: +- Repo truth shows `TenantReviewSectionFactory` and `SupportDiagnosticBundleBuilder` still depend on acknowledged-aware status logic. +- Leaving those consumers out would preserve productive status drift even if the findings register and canonical `/admin` summaries were cleaned. +- This remains bounded because the slice is limited to findings-derived open-work semantics, not broader review-pack, evidence, or diagnostic redesign. + +**Evidence**: +- `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` +- `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` +- `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` +- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` + +**Alternatives considered**: +- Defer review/report and diagnostics entirely. + - Rejected: current productive consumers would still present split workflow truth. +- Broaden into review-pack or diagnostic domain redesign. + - Rejected: outside the smallest cleanup slice. + +## Decision 4 — Keep non-finding acknowledgement domains explicitly out of scope + +**Decision**: Do not rename or remove acknowledgement semantics outside the findings domain, including verification-check acknowledgement, onboarding-verification acknowledgement, and restore impact acknowledgement. + +**Rationale**: +- Those domains carry different user intent and do not prove that findings status compatibility must remain. +- Mixing them into this slice would widen terminology cleanup into unrelated workflows. +- The spec already depends on maintaining bounded ownership and avoiding accidental cross-domain churn. + +**Evidence**: +- `apps/platform/app/Services/Verification/VerificationCheckAcknowledgementService.php` +- `apps/platform/app/Filament/Support/VerificationReportViewer.php` +- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- `apps/platform/app/Filament/Resources/RestoreRunResource.php` + +**Alternatives considered**: +- Normalize every `acknowledge*` term in the repo at once. + - Rejected: too broad and not required for the findings cleanup to be correct. + +## Decision 5 — Keep validation in focused Feature, Unit, and retained guard lanes only + +**Decision**: Prove the cleanup with focused findings workflow tests, focused summary-consumer tests, focused capability cleanup tests, and the already-retained heavy-governance guard coverage. Do not add browser coverage. + +**Rationale**: +- The business risk is shared-seam drift, not browser choreography or async execution. +- The repo already has meaningful findings, generator, summary, and guard test families that can be narrowed or rewritten. +- TEST-GOV-001 prefers the smallest proving lane mix that guards business truth. + +**Evidence**: +- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` +- `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` +- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +**Alternatives considered**: +- Add browser smoke coverage. + - Rejected: low additional value for this cleanup. +- Preserve broad acknowledged-compatibility fixture families. + - Rejected: would keep the removed semantics alive in the suite. + +## Decision 6 — Remove the stale findings capability alias instead of translating it forever + +**Decision**: Delete `tenant_findings.acknowledge` from the canonical capability registry and role mappings, and converge disabled helper text and authorization expectations on the surviving findings permissions. + +**Rationale**: +- The acknowledged alias keeps RBAC language inconsistent with the canonical triage action. +- Capability drift is part of the user-visible problem in this slice, not a separate concern. +- RBAC-UX requires server-side truth to stay on the canonical capability set, not parallel aliases. + +**Evidence**: +- `apps/platform/app/Support/Auth/Capabilities.php` +- `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- `apps/platform/app/Policies/FindingPolicy.php` + +**Alternatives considered**: +- Keep the alias as an undocumented backward-compatibility seam. + - Rejected: preserves the exact semantics blocker this feature is intended to remove. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/spec.md b/specs/254-remove-acknowledged-compat/spec.md new file mode 100644 index 00000000..bd5e542e --- /dev/null +++ b/specs/254-remove-acknowledged-compat/spec.md @@ -0,0 +1,283 @@ +# Feature Specification: Remove Legacy Acknowledged Finding Status Compatibility + +**Feature Branch**: `254-remove-acknowledged-compat` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "Prepare the next repo-based cleanup slice that removes legacy acknowledged finding-status compatibility and collapses findings workflow semantics onto canonical triaged or open handling without changing customer-facing workflow scope or reintroducing repair tooling." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot still carries two parallel findings workflow languages. The product treats `triaged` as the canonical operator meaning, but productive code, shared queries, status filters, badges, role mappings, and workflow tests still preserve `acknowledged` compatibility as if it were an active workflow truth. +- **Today's failure**: Operators and maintainers can still encounter `acknowledged` as a current finding status through shared helpers, filter options, badge labels, capability aliases, and findings-derived summary logic. That weakens workflow clarity, keeps RBAC language inconsistent, and makes shared counts and previews harder to trust. +- **User-visible improvement**: Tenant and workspace operators see one canonical findings workflow language. Status badges, filters, summary counts, helper text, and workflow actions consistently speak in `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, and `risk_accepted` terms only. +- **Smallest enterprise-capable version**: Remove acknowledged compatibility end to end from productive findings status constants and helpers, shared query helpers, shared filter and badge catalogs, capability registry and role mappings, workflow-facing tests, and findings-derived summary surfaces while leaving the rest of the findings lifecycle unchanged. +- **Explicit non-goals**: No backfill-runtime-surface removal in this slice, no broader findings lifecycle redesign, no new states, no migration shim, no historical data migration, no verification-acknowledgement cleanup, no onboarding-verification terminology rewrite, and no new customer-facing workflow surface. +- **Permanent complexity imported**: Net negative. The slice removes a legacy status branch, a capability alias, catalog special-casing, and acknowledged-specific workflow expectations. The only enduring obligation is focused regression coverage that proves one canonical status path remains across findings workflows and findings-derived summaries. +- **Why now**: This candidate remains explicitly open in both product sources and is still repo-proven in productive code. It is smaller and more implementation-ready than creation-time invariant hardening, and it does not depend on an external product decision like External Support Desk / PSA Handoff. +- **Why not local**: The compatibility drift is not confined to one screen or helper. It spans the `Finding` model, workflow service, shared filter catalog, badge language, capability registry, role mappings, canonical summary builders, and workflow-facing tests. A local rename would leave inconsistent product truth in other entry points. +- **Approval class**: Cleanup +- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this slice is intentionally limited to canonical status and RBAC vocabulary cleanup only, while creation-time invariants and external support handoff remain explicit follow-up candidates. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Selection Rationale + +- `Remove Legacy Acknowledged Finding Status Compatibility` is still active in [docs/product/spec-candidates.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/spec-candidates.md#L172) and [docs/product/implementation-ledger.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/implementation-ledger.md#L183) and remains a concrete semantics blocker instead of a speculative cleanup. +- Repo truth still shows acknowledged drift in the canonical findings model and workflow seams, including `Finding::STATUS_ACKNOWLEDGED`, `Finding::openStatusesForQuery()`, `FilterOptionCatalog::findingStatuses()`, `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE`, and findings-derived summary builders. +- This slice is narrower and safer than `Enforce Creation-Time Finding Invariants` because it removes visible and shared workflow ambiguity first without widening generator hardening or recurrence rules. +- `Cross-Tenant Compare and Promotion v1` is not the next preparation target here because the repo already has refreshed Spec 043 ready for later implementation work. +- `External Support Desk / PSA Handoff` stays deferred because it still depends on a concrete external-desk target and broader commercialization workflow decisions, while this cleanup is fully repo-based today. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant + canonical-view +- **Primary Routes**: + - `/admin/t/{tenant}/findings` + - `/admin/t/{tenant}/findings/{record}` + - existing canonical `/admin` summary and inbox surfaces that derive open-finding counts or previews from shared findings queries + - existing findings table and filter surfaces that use shared finding-status catalog options + - existing tenant review, review-pack, and support-diagnostic surfaces only where they render findings-derived open-work summaries, counts, or disclosure text +- **Data Ownership**: + - Tenant-owned `Finding` and related `FindingException` truth remain canonical and keep required `workspace_id` plus `tenant_id` anchors. + - Workspace, canonical summary, review/report, and diagnostic consumers stay derived over tenant-owned findings truth; this feature introduces no new persistence, no mirror entity, and no migration data store. + - Historical pre-production findings rows do not justify a compatibility table, alias, or fallback reader. +- **RBAC**: + - Tenant membership remains the isolation boundary for findings visibility and surviving finding workflow mutations. + - Canonical findings workflow permissions stay capability-first and tenant-scoped; `tenant_findings.acknowledge` is removed rather than preserved as an alias. + - Non-members remain deny-as-not-found and entitled members missing surviving findings capabilities remain forbidden on the affected mutation paths. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Canonical `/admin` summary and inbox surfaces that launch from tenant context continue to prefilter to the current tenant, but they must do so with canonical open-status handling only and without an acknowledged compatibility branch. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace membership and visible-tenant filtering remain authoritative on canonical summary surfaces. The cleanup must not widen findings queries or previews beyond entitled tenants while removing acknowledged compatibility. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: status messaging, workflow helper text, shared badges, shared filter vocabularies, canonical summary counts and previews, capability language +- **Systems touched**: `App\Models\Finding`, `App\Services\Findings\FindingWorkflowService`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\Auth\Capabilities`, `App\Services\Auth\RoleCapabilityMap`, existing findings resource surfaces, existing findings-derived canonical summary builders, `App\Services\TenantReviews\TenantReviewSectionFactory`, and `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder` +- **Existing pattern(s) to extend**: shared finding-status helpers, shared filter and badge catalogs, existing findings workflow actions, existing capability registry, and existing canonical summary builders remain the only supported paths +- **Shared contract / presenter / builder / renderer to reuse**: `Finding::openStatuses()`, shared `BadgeCatalog` finding-status semantics, `FilterOptionCatalog::findingStatuses()`, the canonical capability registry, and existing summary builders that already derive open findings from shared helpers +- **Why the existing shared path is sufficient or insufficient**: the shared paths are sufficient once the legacy acknowledged branch is removed. They are the reason this cleanup must land centrally instead of through page-local exceptions or label overrides. +- **Allowed deviation and why**: none +- **Consistency impact**: triage wording, open counts, badges, filters, review/report disclosure text, diagnostic issue summaries, disabled helper text, and role guidance must all converge on the same canonical finding-status language in the same slice. +- **Review focus**: reviewers must verify that no productive code path, shared filter, badge label, capability alias, review/report disclosure, diagnostic summary, or workflow-facing test still treats `acknowledged` as current findings workflow truth. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: existing findings and summary surfaces remain read/write or read-only according to their current workflows; no `OperationRun` start semantics are introduced or removed by this status cleanup. +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## 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`)* + +N/A - no shared provider or platform seam is widened. This slice only removes legacy findings workflow compatibility inside the existing findings domain. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Tenant findings register and detail: remove acknowledged wording from statuses, filters, and workflow affordances | yes | Native Filament + shared workflow primitives | row actions, header actions, badges, filters | list, detail, action state | no | Canonical triage language only | +| Canonical findings-derived summaries: governance inbox, workspace overview, customer health, and similar previews use canonical open-status handling only | yes | Native Filament + shared summary builders | dashboard cards, inbox previews, counters, drilldowns | page, widget, query state | no | Summary counts and previews stop carrying a hidden acknowledged branch | +| Shared findings status filters and badges: remove legacy acknowledged option and label | yes | Shared badge and filter catalog primitives | status messaging, filter vocabulary, badge semantics | catalog, table filter state | no | No `legacy acknowledged` affordance remains on productive findings surfaces | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Tenant findings register and detail | Primary Decision Surface | Decide how to triage or continue work on a finding | canonical status, severity, due or SLA signals, responsibility, and canonical workflow actions | evidence, history, related runs, and exception detail after opening the finding | Primary because this is where tenant operators act on findings today | Keeps findings work centered on one canonical lifecycle path | Removes a parallel acknowledged label that competes with the real next action | +| Canonical findings-derived summaries | Secondary Context Surface | Decide where follow-up exists before drilling into findings work | counts, previews, and urgency signals derived from canonical open statuses only | the findings register or detail after navigation | Not primary because these surfaces route operators into findings work rather than owning the mutations themselves | Keeps overview or inbox surfaces honest about what is actually open | Removes mismatched counts and pseudo-open summary branches | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Tenant findings register and detail | operator-MSP | canonical findings status, responsibility, due state, and workflow affordances | history, evidence, exception detail, and related runs after opening the record | raw or support-only detail remains on existing deeper routes and capability gates | `Triage finding` or continue canonical workflow | low-level evidence and audit detail stay secondary | status is stated once in canonical terms and deeper sections add evidence rather than alternate vocabulary | +| Canonical findings-derived summaries | operator-MSP | canonical counts, previews, and urgency signals only | secondary drilldowns to the findings register and detail | raw evidence is never the default content on the summary surface | `Open findings` | detailed evidence and audit context stay on deeper surfaces | summaries describe open work once and rely on the findings register for detailed truth | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings register and detail | List / Table / Bulk | CRUD / List-first Resource | Open a finding and continue the canonical workflow | full-row navigation to finding detail | required | existing row `More` actions and detail-header actions only | existing destructive-like actions remain in grouped or detail-header placements | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | tenant context, status filters, responsibility, due state | Findings / Finding | canonical findings workflow state and urgency | none | +| Canonical findings-derived summaries | Monitoring / Queue / Workbench | Context-first summary and preview shell | open a filtered findings view for the relevant tenant or queue | card or preview drilldown into the findings register | forbidden | secondary links only | none | existing canonical `/admin` summary and inbox pages | `/admin/t/{tenant}/findings` | workspace context, tenant filter, preview scope | Findings follow-up / Findings follow-up | where open work exists under the canonical status set | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings register and detail | Tenant operator | Decide how to triage, assign, continue, resolve, close, or risk-accept a finding | List/detail | What should I do next with this finding? | canonical status, severity, responsibility, due or SLA state, and current workflow affordances | evidence, exception history, audit context, related operations | lifecycle, urgency, responsibility, governance validity | TenantPilot only for the existing findings workflow actions | Triage, Start progress, Assign, Resolve, Risk accept | existing destructive-like workflow actions only | +| Canonical findings-derived summaries | Workspace or portfolio operator | Decide where follow-up exists before drilling into findings work | Summary and preview | Where is open findings work waiting right now? | canonical counts, previews, due urgency, and tenant context | deeper findings detail after navigation | lifecycle and urgency only | none on the summary surface itself | Open findings | none | + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature, Unit, Heavy-Governance +- **Validation lane(s)**: fast-feedback, confidence, heavy-governance +- **Why this classification and these lanes are sufficient**: focused feature coverage proves canonical findings workflows and findings-derived summaries stop exposing acknowledged semantics, while narrow unit coverage proves the shared status helper, filter catalog, and capability cleanup stay centralized. One retained heavy-governance guard layer remains appropriate because status-like badge/filter drift can reappear through shared seams even after the main behavior is corrected. +- **New or expanded test families**: focused findings workflow cleanup coverage, focused findings-summary consistency coverage, and bounded filter or capability cleanup coverage. No browser family is introduced. +- **Fixture / helper cost impact**: low and likely net-neutral to slightly negative. Acknowledged-only fixtures and compatibility expectations should be consolidated or deleted instead of adding new heavy setup. +- **Heavy-family visibility / justification**: retained shared guard coverage for status-like tokens and filter-catalog usage remains explicit so a local reintroduction of acknowledged semantics cannot survive through shared UI seams. No new heavy-governance family is introduced. +- **Special surface test profile**: standard-native-filament, global-context-shell +- **Standard-native relief or required special coverage**: standard Filament and domain coverage are sufficient for the findings resource and canonical summaries. Required extra proof is shared guard coverage for badge and filter drift. +- **Reviewer handoff**: reviewers must confirm that acknowledged disappears together from the model helper, workflow rules, shared filter options, shared badge language, role/capability vocabulary, and summary counts or previews. They must also confirm that verification acknowledgement and onboarding acknowledgement remain untouched. +- **Budget / baseline / trend impact**: net-neutral to slightly negative because the slice should remove acknowledged-only compatibility expectations rather than widen the suite. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +## RBAC / Isolation Considerations + +- Tenant findings mutations remain tenant-scoped and follow existing deny-as-not-found versus forbidden semantics: non-members remain `404`, entitled members missing a surviving findings capability remain `403` on mutation. +- Canonical `/admin` summary and inbox surfaces continue to derive only from tenants the actor can see in the current workspace. Removing acknowledged compatibility must not broaden canonical previews or counts beyond the current visible-tenant boundary. +- `tenant_findings.acknowledge` is removed rather than preserved as an alias. Canonical findings workflow language stays capability-first and centered on the surviving findings capabilities. +- This slice does not add a new role family, a new authorization plane, or a new hidden compatibility bypass. + +## Auditability + +- No new audit action ID is introduced by this cleanup. +- Existing findings workflow audit actions such as triage, assignment, in-progress, resolve, close, reopen, and risk acceptance remain the canonical audit language for surviving workflow actions. +- Pre-production historical acknowledgement fields or audit rows do not justify a compatibility renderer, label shim, or preserved active-workflow vocabulary. +- The implementation should ensure that operator-facing surfaces do not continue to advertise `acknowledged` as a current workflow outcome merely because historical audit or fixture data once used it. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Use One Canonical Findings Workflow Language (Priority: P1) + +As a tenant operator, I want findings surfaces to speak in one canonical workflow language so I can triage and continue work without guessing whether `acknowledged` and `triaged` still mean different things. + +**Why this priority**: This is the core user-facing value of the cleanup. If findings still speak two different workflow languages, the slice has failed. + +**Independent Test**: Open the findings register and detail for a tenant with open findings and verify that statuses, filters, badges, and workflow affordances use canonical findings vocabulary only. + +**Acceptance Scenarios**: + +1. **Given** an entitled tenant operator opens the findings register, **When** the page renders, **Then** no current status badge, filter option, or helper text exposes `acknowledged` as a valid findings workflow state. +2. **Given** an open finding is ready for triage or progress, **When** the operator uses the surviving workflow affordances, **Then** the workflow uses canonical `triaged` semantics rather than an acknowledge alias. +3. **Given** a finding has already reached a terminal state, **When** the operator opens it, **Then** the terminal states remain unchanged and no new replacement state is introduced. + +--- + +### User Story 2 - Keep Summary Counts, Reports, and Diagnostics Honest (Priority: P1) + +As a workspace or portfolio operator, I want shared counts, previews, review/report disclosures, and diagnostic summaries to match the findings register so I can trust what is actually open without a hidden acknowledged branch skewing the numbers. + +**Why this priority**: Shared summary drift is one of the main reasons this cleanup cannot stay local. If summaries, review/report disclosures, diagnostics, and lists diverge, operators lose trust in the overview surfaces. + +**Independent Test**: Compare the findings register with the affected canonical summary and inbox surfaces plus findings-derived review/report and support-diagnostic consumers for the same tenant or workspace context and verify that the same canonical open-status set drives all of them. + +**Acceptance Scenarios**: + +1. **Given** a canonical summary surface shows open findings work for a tenant, **When** the operator drills into the findings register, **Then** the summary counts and previews align with the same canonical open-status set. +2. **Given** the operator changes tenant or workspace context, **When** canonical summaries reload, **Then** they continue to derive findings work only from canonical open statuses and the currently entitled tenant set. +3. **Given** a tenant review, review-pack, or support-diagnostic surface renders findings-derived open-work disclosure, **When** the operator opens that surface, **Then** the disclosure text, counts, and issue grouping use the same canonical open-status set and do not present `acknowledged` as current work. + +--- + +### User Story 3 - Keep RBAC Language Canonical (Priority: P2) + +As a tenant manager or owner, I want role guidance and capability-driven findings actions to use one canonical permission language so workflow help and disabled states stay understandable. + +**Why this priority**: The acknowledged alias is not only a status problem. It also keeps role and action guidance inconsistent across the same workflow. + +**Independent Test**: Review capability-driven findings surfaces and role expectations after the cleanup and verify that they reference surviving findings capabilities only. + +**Acceptance Scenarios**: + +1. **Given** a tenant member can triage findings, **When** the findings UI explains or gates the action, **Then** it refers to the canonical triage capability and not an acknowledge alias. +2. **Given** a tenant member lacks the required surviving capability, **When** they attempt the affected findings action, **Then** the existing forbidden behavior remains and no acknowledge-specific permission branch is used. + +### Edge Cases + +- Local or historical pre-production rows may still contain `acknowledged`; this slice does not add a migration shim, fallback reader, or preserved UI label to keep that branch alive. +- Removing acknowledged from shared helpers must update list surfaces, summary counts, preview queries, filters, and badges together; otherwise the cleanup would create a new mismatch instead of removing one. +- Verification-check acknowledgement and onboarding-verification acknowledgement are separate domains and must not be renamed or removed as collateral damage in this slice. +- Resolved, closed, and risk-accepted findings behavior remains distinct and must not be collapsed while removing the acknowledged compatibility path. + +## Requirements *(mandatory)* + +**Constitution alignment (LEAN-001 / STATE-001 / SPEC-GATE-001):** This is a pre-production cleanup slice. It removes a legacy findings workflow branch rather than introducing new state, persistence, or abstraction. Compatibility shims, fallback readers, historical fixture preservation, and capability aliases are out of scope. + +**Constitution alignment (XCUT-001 / BADGE-001):** Because the drift survives in shared status helpers, shared filter catalogs, shared badge language, and shared summary builders, the cleanup must land through the shared paths themselves. No page-local override or secondary presenter may keep acknowledged alive. + +**Constitution alignment (RBAC-UX):** Tenant membership and capability rules stay unchanged except for removing the acknowledged alias from the findings capability vocabulary. Non-members remain `404`; entitled members missing the surviving capability remain `403` on mutations. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature, unit, and bounded heavy-governance guard coverage. The slice should remove acknowledged-only expectations rather than creating a broader or heavier new test family. + +**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / OPSURF-001):** Findings surfaces remain native Filament or shared summary surfaces. Canonical findings vocabulary must stay consistent across badges, filters, helper text, row actions, disabled states, and drilldown summaries without introducing a new local status language. + +### Functional Requirements + +- **FR-254-001**: The system MUST retire `acknowledged` as a productive findings workflow status and remove any status helper that treats it as a current canonical findings state. +- **FR-254-002**: Shared open-status query helpers and findings-derived summary builders MUST rely on the canonical open findings status set only and MUST NOT preserve a hidden acknowledged compatibility branch. +- **FR-254-003**: Shared findings filter catalogs, status badges, and related helper text MUST stop exposing `acknowledged` or `legacy acknowledged` as a valid findings workflow affordance. +- **FR-254-004**: Findings workflow actions and guards MUST authorize and mutate against canonical triage semantics only; the active findings workflow must not require or preserve an acknowledge alias. +- **FR-254-005**: The canonical capability registry, role mappings, and workflow-facing authorization expectations MUST remove `tenant_findings.acknowledge` rather than keeping it as a stale alias. +- **FR-254-006**: Productive code paths and workflow-facing tests MUST stop writing, expecting, or advertising `acknowledged` as a valid current findings workflow status. +- **FR-254-007**: Existing findings flows remain functional and in scope only for regression protection across `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, and `risk_accepted` outcomes. +- **FR-254-008**: The feature MUST NOT reintroduce repair tooling, backfill semantics, new workflow states, migration shims, fallback readers, or historical compatibility logic to preserve the removed acknowledged branch. +- **FR-254-009**: The feature MUST NOT alter verification-check acknowledgement, onboarding-verification acknowledgement, or other non-finding acknowledgement domains unless a path directly depends on findings status compatibility, in which case that dependency must be removed instead of widening the slice. +- **FR-254-010**: Tenant-owned findings keep existing `workspace_id` plus `tenant_id` ownership anchors; no new persisted alias, auxiliary mapping table, or compatibility truth is introduced. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Findings resource list/detail | `app/Filament/Resources/FindingResource.php` and related pages | no acknowledge-named action or helper text remains; surviving workflow utilities keep canonical wording | full-row click to finding detail remains canonical | existing canonical findings workflow actions only | existing grouped bulk actions only, with no acknowledged vocabulary | existing empty state remains unchanged | existing detail-header workflow actions keep canonical wording only | `N/A` | yes, unchanged for surviving workflow actions | Remove acknowledged vocabulary from filters, badges, disabled helper text, and action guidance without introducing a replacement action | +| Canonical findings summaries and inbox shells | existing `/admin` pages and widgets using shared findings summary builders | no new header actions; drilldown links only | same-page cards, counters, or preview links remain canonical | none | none | existing empty states remain, but they must not mention acknowledged compatibility | `N/A` | `N/A` | no new audit event | This slice updates counts, previews, and wording only | + +### Key Entities *(include if feature involves data)* + +- **Canonical finding status**: The current findings lifecycle language used on productive findings surfaces and queries. After this cleanup it consists only of the surviving canonical statuses already present in the findings workflow. +- **Findings-derived summary surface**: Any canonical `/admin` overview, inbox, widget, or preview surface that derives open findings work from the shared finding-status helper rather than from a local list of status strings. +- **Findings capability mapping**: The shared capability and role-mapping truth that determines which tenant members can use the surviving findings workflow actions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Zero productive findings filters, badges, helper texts, or workflow affordances expose `acknowledged` as a current findings workflow status. +- **SC-002**: Zero supported findings permissions or role mappings expose `tenant_findings.acknowledge` after the cleanup. +- **SC-003**: All in-scope findings-derived summary surfaces and findings register surfaces use the same canonical open-status set during regression validation. +- **SC-004**: Representative regression proof still passes for the surviving findings workflow from `new` through `triaged`, `in_progress`, `resolved`, and `risk_accepted` outcomes without introducing a replacement compatibility branch. + +## Dependencies + +- The canonical findings model and workflow seams where acknowledged compatibility still survives, including the shared findings status helper, workflow service, filter catalog, and capability registry. +- Existing findings-derived summary builders that currently rely on shared open-status helpers for inbox, health, and preview surfaces. +- Existing findings resource and workflow-facing tests that still preserve or assert acknowledged semantics. + +## Assumptions + +- The current repo truth treats `triaged` as the canonical operator-facing findings workflow semantics and keeps `acknowledged` only as cleanup debt. +- LEAN-001 still applies because the product remains pre-production; historical or local findings rows do not justify compatibility behavior in this slice. +- Spec 253 already covers the adjacent backfill-runtime-surface cleanup, so this slice should not reopen that work while cleaning status semantics. +- Cross-Tenant Compare and Promotion is already refreshed as Spec 043 and is therefore not the next open preparation target here. +- Verification-check acknowledgement remains a separate domain and must not be pulled into this findings cleanup. + +## Risks + +- Hidden acknowledged residues may still survive in shared summary builders, status badges, or old test fixtures even after the main findings workflow seam is cleaned. +- Local or stale pre-production data containing acknowledged may surface unexpected failures if implementation removes compatibility before all relevant fixtures and productive write paths are updated together. +- Overbroad cleanup could accidentally touch verification or onboarding acknowledgement semantics, which would violate the intended slice boundary. + +## Out of Scope + +- Removing or revisiting the already-separated backfill-runtime-surface cleanup slice +- Enforcing creation-time finding invariants or generator hardening beyond what is needed to stop preserving acknowledged compatibility +- Broader findings lifecycle redesign, new workflow states, or new customer-facing workflow surfaces +- Historical data migration, translation helpers, fallback readers, or compatibility-specific test preservation +- Verification-check acknowledgement, onboarding acknowledgement UX, or non-finding acknowledgement domains +- External Support Desk / PSA Handoff or other commercialization workflow work + +## Follow-up Candidates + +1. `Enforce Creation-Time Finding Invariants` remains the next findings hardening candidate after this semantics cleanup because generator and recurrence guarantees still need explicit protection. +2. `External Support Desk / PSA Handoff` remains an explicit deferred candidate for commercialization flow maturity once the repo names a concrete external desk target. +3. `Cross-Tenant Compare and Promotion v1` remains covered by refreshed Spec 043 and should continue on that track instead of being reopened inside this cleanup slice. diff --git a/specs/254-remove-acknowledged-compat/tasks.md b/specs/254-remove-acknowledged-compat/tasks.md new file mode 100644 index 00000000..f956103a --- /dev/null +++ b/specs/254-remove-acknowledged-compat/tasks.md @@ -0,0 +1,238 @@ +# Tasks: Remove Legacy Acknowledged Finding Status Compatibility + +**Input**: Design documents from `/specs/254-remove-acknowledged-compat/` +**Prerequisites**: `plan.md`, `spec.md`, `checklists/requirements.md` + +**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/254-remove-acknowledged-compat/plan.md`, plus the retained `heavy-governance` guards already called out there. Prefer focused new proof in `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php`, `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php`, `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php`, and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`, then prove summary convergence through the existing downstream customer-health, governance-inbox, baseline, alert, tenant-review, and support-diagnostics tests instead of widening the suite. +**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migration shims, fallback readers, or repair tooling. +**RBAC**: Preserve current `/admin/t/{tenant}` tenant isolation, deny-as-not-found `404` for non-members or out-of-scope users, and `403` for in-scope capability failures on surviving findings actions. Remove `tenant_findings.acknowledge` from the capability registry and role mappings without widening any unrelated authorization behavior. +**UI / Surface Guardrails**: This is a `review-mandatory` cleanup across native Filament findings surfaces and canonical `/admin` summary or inbox shells. Keep `standard-native-filament` relief for the tenant findings resource and `global-context-shell` proof for workspace summaries, and do not add panels, assets, local status presenters, or replacement workflow affordances. +**Badges / Filters (BADGE-001 / XCUT-001)**: Remove the legacy acknowledged branch through shared findings status seams only. `BadgeCatalog`, `FilterOptionCatalog`, and the existing findings resource or summary builders remain the supported paths; no page-local mapping or one-off status label is allowed. +**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` and `US2` in parallel -> `US3` -> final cleanup and validation, because canonical RBAC proof only matters after the workflow and summary surfaces stop carrying acknowledged compatibility. + +**Implementation note**: Several downstream consumers already converged automatically once `Finding::openStatusesForQuery()` and the shared RBAC/filter seams were corrected. Where direct edits in the originally listed consumer files proved unnecessary, completion below reflects the shared-helper cleanup plus targeted validation in the existing downstream tests rather than redundant file-local rewrites. + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback` plus `confidence`, with retained `heavy-governance` guards only in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, and remains the narrowest sufficient proof for the removed compatibility branch. +- [x] New or changed tests stay in focused `Feature` and `Unit` files only; no browser lane or new heavy-governance family is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; acknowledged-specific setup is deleted or localized instead of becoming a new shared default. +- [x] Planned validation commands stay limited to the targeted Sail test commands captured in `specs/254-remove-acknowledged-compat/plan.md` and the final validation phase below. +- [x] The declared surface test profile stays `standard-native-filament` plus `global-context-shell`; no additional surface exception is introduced. +- [x] Any material suite-footprint or residue follow-up resolves inside this feature as `document-in-feature` or `reject-or-split`, not as silent scope drift. + +## Phase 1: Setup (Shared Cleanup Anchors) + +**Purpose**: Lock the concrete removal inventory, out-of-scope boundaries, and proving commands before implementation starts. + +- [x] T001 [P] Verify the productive acknowledged inventory across `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Services/Auth/RoleCapabilityMap.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T002 [P] Verify the shared summary, alert, and query-consumer inventory across `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` +- [x] T003 [P] Verify the out-of-scope acknowledgement domains stay untouched across `apps/platform/app/Services/Verification/VerificationCheckAcknowledgementService.php`, `apps/platform/app/Filament/Support/VerificationReportViewer.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/tests/Feature/Verification/VerificationCheckAcknowledgementTest.php`, and `apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php` +- [x] T004 [P] Verify the minimum Sail validation commands and file-scoped coverage expectations in `specs/254-remove-acknowledged-compat/plan.md`, `specs/254-remove-acknowledged-compat/spec.md`, and `specs/254-remove-acknowledged-compat/checklists/requirements.md` + +**Checkpoint**: The cleanup boundaries, shared seams, and proving commands are locked before any runtime file is changed. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Make the intended regression proof and stale compatibility inventory explicit before removing shared semantics. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T005 [P] Create the core status and workflow proof files `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` +- [x] T006 [P] Create the shared-surface and RBAC proof files `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php` and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`, and prove downstream summary consumers through the existing baseline, workspace health, tenant review, support-diagnostics, and alerts tests that already exercise the shared open-status helper. +- [x] T007 [P] Verify retained guard coverage in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` so findings acknowledged compatibility is treated as removed repo truth rather than tolerated legacy drift +- [x] T008 [P] Audit stale compatibility fixtures and helpers across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, and `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php` + +**Checkpoint**: Proof files, guard expectations, and stale compatibility anchors are explicit and ready for bounded implementation work. + +--- + +## Phase 3: User Story 1 - Use One Canonical Findings Workflow Language (Priority: P1) 🎯 MVP + +**Goal**: Remove acknowledged as a productive findings lifecycle concept so tenant findings list, detail, inbox, badges, and filters all speak in one canonical workflow language. + +**Independent Test**: Open the tenant findings register, detail, and assignee inbox for a tenant with active findings and verify that statuses, filters, badges, helper text, and workflow affordances use canonical findings vocabulary only. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Add canonical status-contract and lifecycle assertions in `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` +- [x] T010 [P] [US1] Add shared filter, badge, and list-surface absence proof in `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` + +### Implementation for User Story 1 + +- [x] T011 [US1] Remove productive acknowledged status constants, canonicalization shims, `acknowledge()` helper behavior, and acknowledged factory state usage from `apps/platform/app/Models/Finding.php` and `apps/platform/database/factories/FindingFactory.php` +- [x] T012 [US1] Collapse workflow transitions and policy checks onto canonical triage semantics in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Policies/FindingPolicy.php` +- [x] T013 [US1] Remove acknowledged filter options, acknowledged detail metadata copy, and acknowledged-specific visibility branches from `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T014 [US1] Rewrite stale workflow helper and compatibility assertions in `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, and `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php` + +**Checkpoint**: User Story 1 is independently functional and no productive findings surface still advertises acknowledged as current workflow truth. + +--- + +## Phase 4: User Story 2 - Keep Summary Counts and Previews Honest (Priority: P1) + +**Goal**: Make every shared summary, preview, alert, and consumer query derive open findings from the same canonical status set used by the findings register. + +**Independent Test**: Compare the tenant findings register with the affected `/admin` summary and inbox surfaces for the same tenant or workspace context and verify that counts, previews, drilldowns, and alerts align with the same canonical open-status set. + +### Tests for User Story 2 + +- [x] T015 [P] [US2] Prove shared summary alignment through the existing downstream tests in `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php` and `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`, with the governance-inbox stale-operations expectation recorded separately as an unrelated existing operation-age failure. +- [x] T016 [P] [US2] Add consumer regression coverage for overview, baseline, tenant-review, diagnostics, and alert paths in `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`, and `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php` + +### Implementation for User Story 2 + +- [x] T017 [US2] Collapse canonical open-status summary builders via the shared `Finding::openStatusesForQuery()` cleanup and validate the unchanged consumers in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T018 [US2] Remove acknowledged-specific active-count and review-report branches from `apps/platform/app/Support/Baselines/BaselineCompareStats.php` while validating that `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` already converged through the shared open-status helper. +- [x] T019 [US2] Collapse alert, generator, and hygiene consumers onto canonical open statuses via the shared helper in `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` +- [x] T020 [US2] Rewrite acknowledged-compatibility expectations in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and validate the unaffected downstream consumers in `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php` + +**Checkpoint**: User Story 2 is independently functional and shared counts, previews, review packs, diagnostics, and alerts all reflect the same canonical findings-open set. + +--- + +## Phase 5: User Story 3 - Keep RBAC Language Canonical (Priority: P2) + +**Goal**: Remove the acknowledge capability alias so role guidance, authorization checks, and disabled findings actions all use the surviving canonical findings permission language only. + +**Independent Test**: Review capability-driven findings surfaces and role expectations after the cleanup and verify that they reference surviving findings capabilities only while preserving current `404` versus `403` semantics. + +### Tests for User Story 3 + +- [x] T021 [P] [US3] Add positive and negative capability-alias removal coverage in `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` +- [x] T022 [P] [US3] Update role-matrix and UI-enforced findings permission proof in `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`, and validate the surviving UI-enforced surface contract in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +### Implementation for User Story 3 + +- [x] T023 [US3] Remove `tenant_findings.acknowledge` from `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- [x] T024 [US3] Collapse acknowledge-specific authorization branches and findings UI enforcement onto surviving capabilities in `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/app/Filament/Resources/FindingResource.php` +- [x] T025 [US3] Rewrite stale RBAC and capability-alias expectations in `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`, and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + +**Checkpoint**: User Story 3 is independently functional and no supported findings permission path or role expectation still names an acknowledge alias. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Remove remaining stale compatibility residue, keep scope boundaries honest, and run the narrow validation workflow. + +- [x] T026 [P] Remove final acknowledged-compatibility residue from findings-only helper and proof surfaces in `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, and `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php` after the story-specific rewrites land +- [x] T027 [P] Run a residue search for `STATUS_ACKNOWLEDGED`, `TENANT_FINDINGS_ACKNOWLEDGE`, `legacy acknowledged`, and `acknowledge(` across `apps/platform/app/`, `apps/platform/database/factories/`, and `apps/platform/tests/`, then classify any remaining match as in-scope cleanup, allowed non-finding domain, or `reject-or-split` +- [x] T028 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the cleanup across `apps/platform/app/`, `apps/platform/database/factories/`, and `apps/platform/tests/` +- [x] T029 [P] Run the focused workflow, filter, and RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` +- [x] T030 [P] Run the focused summary and consumer Sail command using the existing downstream proof files `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Alerts/SlaDueAlertTest.php`, with the governance-inbox stale-operations expectation recorded as an unrelated existing failure outside the acknowledged-status cleanup. +- [x] T031 [P] Run the retained heavy-governance guards `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, then verify no out-of-scope verification or onboarding acknowledgement file from `T003` changed without an explicit split decision +- [x] T032 [P] Verify FR-254-010 explicitly by confirming `Finding` keeps `workspace_id` plus `tenant_id` as unchanged ownership anchors and that no file under `apps/platform/database/migrations/` changed and no new persisted compatibility artifact, alias table, fallback reader, or migration-backed truth was introduced while implementing this slice; if ownership-anchor or persistence widening appears, stop and split the work instead of absorbing it here + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the exact removal inventory, boundaries, and proving commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, guard expectations, and stale compatibility anchors are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and is part of the MVP delivery. +- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it targets shared summary, alert, and query consumers behind the same canonical status set. +- **User Story 3 (Phase 5)**: Depends on User Story 1 because RBAC language should be validated only after findings workflow surfaces stop advertising acknowledged semantics; it can overlap with late User Story 2 cleanup once capability surfaces are isolated. +- **Polish (Phase 6)**: Depends on all desired user stories being complete so residue checks, formatting, and focused validation run on the final cleanup shape. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: No dependencies beyond Foundational. +- **US3**: Depends on US1 and should validate alongside completed US2 summary cleanup. + +### Within Each User Story + +- Add or update the story tests first and confirm they fail before cleanup edits are considered complete. +- Remove shared compatibility branches centrally instead of hiding acknowledged semantics on one page or in one helper. +- Do not keep compatibility aliases, fallback readers, data-migration shims, or replacement workflow affordances. +- Keep backfill-runtime-surface removal, creation-time invariants hardening, broader lifecycle redesign, verification acknowledgement cleanup, onboarding acknowledgement cleanup, and support-desk work out of scope. + +### Parallel Opportunities + +- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup. +- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work. +- `T009` and `T010` can run in parallel for User Story 1 before `T011`, `T012`, `T013`, and `T014`. +- `T015` and `T016` can run in parallel for User Story 2 before `T017`, `T018`, `T019`, and `T020`. +- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete. +- `T021` and `T022` can run in parallel for User Story 3 before `T023`, `T024`, and `T025`. +- `T029`, `T030`, and `T031` can run in parallel during final validation. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T009 apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php + apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php +T010 apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php + apps/platform/tests/Unit/Badges/FindingBadgesTest.php + apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php + +# User Story 1 implementation after the tests are in place +T011 apps/platform/app/Models/Finding.php + apps/platform/database/factories/FindingFactory.php +T012 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Policies/FindingPolicy.php +T013 apps/platform/app/Support/Filament/FilterOptionCatalog.php + apps/platform/app/Filament/Resources/FindingResource.php + apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T015 apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php + apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +T016 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php + apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php + apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php + apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php + apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php + apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php + +# User Story 2 implementation after the tests are in place +T017 apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php + apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php + apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php + apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +T018 apps/platform/app/Support/Baselines/BaselineCompareStats.php + apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php + apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +T019 apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/Baselines/BaselineAutoCloseService.php + apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel +T021 apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php + apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php +T022 apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php + apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php + +# User Story 3 implementation after the tests are in place +T023 apps/platform/app/Support/Auth/Capabilities.php + apps/platform/app/Services/Auth/RoleCapabilityMap.php +T024 apps/platform/app/Policies/FindingPolicy.php + apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Filament/Resources/FindingResource.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 and 2) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Run `T028`, `T029`, and `T030` before widening into capability-alias cleanup. + +### Incremental Delivery + +1. Lock the shared seams, out-of-scope domains, and proving commands. +2. Remove acknowledged from the findings model, workflow, filter, badge, and Filament surfaces. +3. Remove acknowledged from summary builders, review or report helpers, alerts, and other shared query consumers. +4. Remove the stale capability alias and role expectations once findings workflow language is already canonical. +5. Finish with residue searches, formatting, and the focused Sail commands. + +### Parallel Team Strategy + +1. One contributor can own the findings model, workflow, and Filament cleanup (`US1`) while another owns shared summaries, alerts, review helpers, and query consumers (`US2`) after Phase 2. +2. Once the two P1 stories land, a focused pass can remove the capability alias and RBAC wording (`US3`) without reopening summary or workflow decisions. +3. A final pass can remove stale residue, run Pint, and execute the three focused Sail validation commands. + +--- + +## Notes + +- Suggested MVP scope: Phase 1 through Phase 4 only. Canonical workflow cleanup without summary or query-consumer alignment is not sufficient for this feature. +- Explicit non-goals remain: backfill-runtime-surface removal, creation-time invariant hardening, broader lifecycle redesign, verification or onboarding acknowledgement cleanup, new workflow states, migration shims, repair tooling, and support-desk workflows. +- Filament stays on Livewire v4 and no panel/provider or asset strategy changes are needed; `FindingResource` already has a view page, so global-search behavior does not need separate tasking in this slice. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths. \ No newline at end of file -- 2.45.2 From 51ea80ca052d8058061dfc26760dcc0bf72f4385 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 12:26:21 +0000 Subject: [PATCH 4/7] =?UTF-8?q?Automatische=20PR:=20255-enforce-finding-cr?= =?UTF-8?q?eation-invariants=20=E2=86=92=20platform-dev=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatisch erstellt: Commit & Push aus Workspace (WIP) Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/298 --- .../app/Jobs/CompareBaselineToTenantJob.php | 22 +- .../EntraAdminRolesFindingGenerator.php | 22 +- .../PermissionPostureFindingGenerator.php | 20 +- .../Baselines/BaselineCompareFindingsTest.php | 127 ++++++++ .../EntraAdminRolesFindingGeneratorTest.php | 48 +++ .../PermissionPostureFindingGeneratorTest.php | 39 +++ .../checklists/requirements.md | 48 +++ .../finding-creation-invariants.contract.yaml | 101 ++++++ .../data-model.md | 130 ++++++++ .../plan.md | 295 ++++++++++++++++++ .../quickstart.md | 39 +++ .../research.md | 126 ++++++++ .../spec.md | 280 +++++++++++++++++ .../tasks.md | 242 ++++++++++++++ 14 files changed, 1524 insertions(+), 15 deletions(-) create mode 100644 specs/255-enforce-finding-creation-invariants/checklists/requirements.md create mode 100644 specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml create mode 100644 specs/255-enforce-finding-creation-invariants/data-model.md create mode 100644 specs/255-enforce-finding-creation-invariants/plan.md create mode 100644 specs/255-enforce-finding-creation-invariants/quickstart.md create mode 100644 specs/255-enforce-finding-creation-invariants/research.md create mode 100644 specs/255-enforce-finding-creation-invariants/spec.md create mode 100644 specs/255-enforce-finding-creation-invariants/tasks.md diff --git a/apps/platform/app/Jobs/CompareBaselineToTenantJob.php b/apps/platform/app/Jobs/CompareBaselineToTenantJob.php index 06857447..61e10e57 100644 --- a/apps/platform/app/Jobs/CompareBaselineToTenantJob.php +++ b/apps/platform/app/Jobs/CompareBaselineToTenantJob.php @@ -1871,8 +1871,11 @@ private function upsertFindings( } else { $this->observeFinding( finding: $finding, + tenant: $tenant, observedAt: $observedAt, currentOperationRunId: (int) $this->operationRun->getKey(), + severity: (string) $driftItem['severity'], + slaPolicy: $slaPolicy, ); } @@ -1947,12 +1950,21 @@ private function upsertFindings( ]; } - private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void + private function observeFinding( + Finding $finding, + Tenant $tenant, + CarbonImmutable $observedAt, + int $currentOperationRunId, + string $severity, + FindingSlaPolicy $slaPolicy, + ): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } + $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at); + if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { $finding->last_seen_at = $observedAt; } @@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i } elseif ($timesSeen < 1) { $finding->times_seen = 1; } + + if ($finding->sla_days === null) { + $finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt); + } } /** diff --git a/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php b/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php index b41f7558..733e4f68 100644 --- a/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php +++ b/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php @@ -163,7 +163,7 @@ private function upsertFinding( ->first(); if ($existing instanceof Finding) { - $this->observeFinding($existing, $observedAt); + $this->observeFinding($existing, $tenant, $observedAt, $severity); $existing->forceFill([ 'severity' => $severity, @@ -253,7 +253,7 @@ private function handleGaAggregate( ->first(); if ($existing instanceof Finding) { - $this->observeFinding($existing, $observedAt); + $this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH); $existing->forceFill([ 'severity' => Finding::SEVERITY_HIGH, @@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy return $this->slaPolicy ?? app(FindingSlaPolicy::class); } - private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void + private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } + $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at); + $lastSeenAt = $finding->last_seen_at; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { $finding->last_seen_at = $observedAt; $finding->times_seen = max(0, $timesSeen) + 1; - - return; + } elseif ($timesSeen < 1) { + $finding->times_seen = 1; } - if ($timesSeen < 1) { - $finding->times_seen = 1; + $slaPolicy = $this->resolveSlaPolicy(); + + if ($finding->sla_days === null) { + $finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt); } } diff --git a/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php b/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php index 55c87959..2796956f 100644 --- a/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +++ b/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php @@ -140,7 +140,7 @@ private function handleMissingPermission( ->first(); if ($finding instanceof Finding) { - $this->observeFinding($finding, $observedAt); + $this->observeFinding($finding, $tenant, $observedAt, $severity); $finding->forceFill([ 'severity' => $severity, @@ -216,7 +216,7 @@ private function handleErrorPermission( ->first(); if ($existing instanceof Finding) { - $this->observeFinding($existing, $observedAt); + $this->observeFinding($existing, $tenant, $observedAt, $severity); $existing->forceFill([ 'severity' => $severity, @@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu return CarbonImmutable::now(); } - private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void + private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } + $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at); + $lastSeenAt = $finding->last_seen_at; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { $finding->last_seen_at = $observedAt; $finding->times_seen = max(0, $timesSeen) + 1; - - return; + } elseif ($timesSeen < 1) { + $finding->times_seen = 1; } - if ($timesSeen < 1) { - $finding->times_seen = 1; + if ($finding->sla_days === null) { + $finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt); } } diff --git a/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php index 97865402..f187ae45 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -528,6 +528,133 @@ expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1); }); +it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + \Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z')); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $baselineContract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-x-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $hasher->hashNormalized($baselineContract), + 'meta_jsonb' => ['display_name' => $displayName], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'], + 'display_name' => $displayName, + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run1))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->sole(); + + $expectedSlaDays = (int) $finding->sla_days; + $expectedDueAt = $finding->due_at?->toIso8601String(); + + expect($expectedSlaDays)->toBeGreaterThan(0) + ->and($expectedDueAt)->not->toBeNull(); + + $finding->forceFill([ + 'sla_days' => null, + 'due_at' => null, + ])->save(); + + \Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z')); + + $run2 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run2))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding->refresh(); + + expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe($expectedSlaDays) + ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt); + + \Carbon\CarbonImmutable::setTestNow(); +}); + it('does not create new finding identities when a new snapshot is captured', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php index 52be5f0e..8d392607 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php @@ -195,6 +195,54 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00'); }); +it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void { + [$user, $tenant] = createMinimalUserWithTenant(); + + $generator = makeGenerator(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $generator->generate($tenant, buildPayload( + [gaRoleDef()], + [makeEntraAssignment('a1', 'def-ga', 'user-1')], + '2026-02-24T10:00:00Z', + )); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) + ->firstOrFail(); + + $expectedDueAt = $finding->due_at?->toIso8601String(); + + expect($finding->sla_days)->toBe(3) + ->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00'); + + $finding->forceFill([ + 'sla_days' => null, + 'due_at' => null, + ])->save(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z')); + $result = $generator->generate($tenant, buildPayload( + [gaRoleDef()], + [makeEntraAssignment('a1', 'def-ga', 'user-1')], + '2026-02-24T11:00:00Z', + )); + + $finding->refresh(); + + expect($result->created)->toBe(0) + ->and($result->unchanged)->toBe(1) + ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe(3) + ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt); + + CarbonImmutable::setTestNow(); +}); + it('auto-resolves when assignment is removed', function (): void { [$user, $tenant] = createMinimalUserWithTenant(); diff --git a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php index 1d071e97..c6cbd5b8 100644 --- a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php @@ -149,6 +149,45 @@ function errorPermission(string $key, array $features = []): array CarbonImmutable::setTestNow(); }); +it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void { + [$user, $tenant] = createUserWithTenant(); + $generator = app(PermissionPostureFindingGenerator::class); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $generator->generate($tenant, buildComparison([ + missingPermission('Perm.A', ['policy-sync', 'backup']), + ])); + + $finding = Finding::query()->where('tenant_id', $tenant->getKey())->firstOrFail(); + $expectedDueAt = $finding->due_at?->toIso8601String(); + + expect($finding->sla_days)->toBe(7) + ->and($expectedDueAt)->toBe('2026-03-03T10:00:00+00:00'); + + $finding->forceFill([ + 'sla_days' => null, + 'due_at' => null, + ])->save(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z')); + $result = $generator->generate($tenant, buildComparison([ + missingPermission('Perm.A', ['policy-sync', 'backup']), + ])); + + $finding->refresh(); + + expect($result->findingsCreated)->toBe(0) + ->and($result->findingsUnchanged)->toBe(1) + ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe(7) + ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt); + + CarbonImmutable::setTestNow(); +}); + // (5) Re-opens resolved finding when permission revoked again it('re-opens resolved finding when permission is revoked again', function (): void { [$user, $tenant] = createUserWithTenant(); diff --git a/specs/255-enforce-finding-creation-invariants/checklists/requirements.md b/specs/255-enforce-finding-creation-invariants/checklists/requirements.md new file mode 100644 index 00000000..c580066c --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Enforce Creation-Time Finding Invariants + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Repo-specific classes, routes, file paths, and validation commands appear only where they are required to keep the three active writer families and proof obligations unambiguous +- [x] Focused on user value and business needs +- [x] Written for product and review stakeholders, with repo-grounded detail only where the bounded invariant target would otherwise stay ambiguous +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria stay outcome-oriented even though the package names concrete writer families and proof files needed to bound the slice +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No unbounded implementation plan leaks into the specification; repo-specific commands and paths stay limited to selection, dependency, and validation context + +## Test Governance Review + +- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, with the three writer suites as the primary proof and only bounded recurrence, consumer, and trigger-authorization regressions where FR-255-005, FR-255-006, FR-255-009, and FR-255-011 require them. +- [x] No new browser or heavy-governance family is introduced; adjacent proof remains inside existing feature suites only. +- [x] Suite-cost outcome stays bounded and reviewable: the package reuses existing writer, recurrence, consumer, and auth suites without adding a new default-heavy harness. + +## Review Outcome + +- [x] Review outcome class: `acceptable-special-case` +- [x] Workflow outcome: `keep` +- [x] Review-note location is explicit: guardrail, lane-fit, and bounded-proof notes live in `spec.md`, `plan.md`, `tasks.md`, and this checklist. + +## Notes + +- Repo-surface names, validation commands, and current writer/test anchors are intentionally present because this prep package must distinguish the three active finding writers from already-completed adjacent cleanup specs. +- The spec remains behavior-first: write-time lifecycle readiness, recurrence identity, reopen truth, and unchanged RBAC/tenant isolation are the product outcomes; repo details only keep the package reviewable and bounded. +- No blocking open question remains for safe planning. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml b/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml new file mode 100644 index 00000000..321e5534 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml @@ -0,0 +1,101 @@ +version: 1 +kind: finding-creation-invariants + +scope: + goal: enforce lifecycle-ready finding creation and recurrence or reopen semantics across the active finding writers only + non_goals: + - repair tooling or backfill runtime surfaces + - new workflow states or new findings lifecycle families + - customer-facing workflow expansion + - compare refresh work + - external support handoff + - broader findings redesign + - silent database-constraint rollout + stop_conditions: + - another shipped finding writer is discovered outside the three confirmed paths + - application-level write enforcement proves insufficient without a migration or DB constraint + - the only available implementation shape is a new generic invariant framework + +active_writer_families: + baseline_compare: + owner_files: + - apps/platform/app/Jobs/CompareBaselineToTenantJob.php + identity: + canonical_key: recurrence_key + fingerprint_contract: fingerprint equals recurrence_key + observation_boundary: + duplicate_guard: current_operation_run_id prevents double counting the same compare run + entra_admin_roles: + owner_files: + - apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + identity: + canonical_key: existing role-assignment or aggregate fingerprint + observation_boundary: + duplicate_guard: later observedAt advances seen history + permission_posture: + owner_files: + - apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + identity: + canonical_key: existing permission or error fingerprint + observation_boundary: + duplicate_guard: later observedAt advances seen history + +shared_lifecycle_contract: + model: + owner_file: apps/platform/app/Models/Finding.php + invariants: + - workspace_id and tenant_id remain required ownership anchors + - no new status or reason-code family is introduced + reopen_service: + owner_file: apps/platform/app/Services/Findings/FindingWorkflowService.php + requirement: + - terminal findings reopen only through reopenBySystem + - reopened_at is set + - resolved and closed markers clear according to current service behavior + - sla_days and due_at are recalculated from reopenedAt + - existing audit and alert side effects are preserved + +lifecycle_invariants: + create: + required_fields: + - status is new + - first_seen_at equals observedAt + - last_seen_at equals observedAt + - times_seen equals 1 + - sla_days is initialized when the current severity policy returns a value + - due_at is initialized when the current severity policy requires due-state truth + contextual_fields: + - current_operation_run_id remains populated where the current writer already sets it + refresh_existing: + required_behavior: + - the same canonical finding identity is reused + - missing first_seen_at, last_seen_at, and times_seen are repaired inline + - missing sla_days or due_at covered by this slice are repaired inline without a second-pass repair tool + - already-valid lifecycle fields are not reset unnecessarily + reopen: + required_behavior: + - the same canonical finding identity is reopened, not duplicated + - resolved_at and resolved_reason clear on reopen + - first_seen_at is retained + - last_seen_at and times_seen advance according to the family observation rule + +downstream_regression_consumers: + findings_surfaces: + owner_files: + - apps/platform/app/Filament/Resources/FindingResource.php + - apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + - apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php + - apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php + expectation: + - no design change is required; these surfaces should continue to read truthful due_at and reopened_at data from the same Finding records + +validation_expectations: + required_feature_proof: + - baseline compare proves create readiness, same-run retry protection, reopened reuse, and inline repair of incomplete lifecycle fields + - Entra admin roles proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields + - permission posture proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields + excluded_lanes: + - browser + - heavy-governance + migration_posture: + - no new migration or schema artifact is allowed in this slice \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/data-model.md b/specs/255-enforce-finding-creation-invariants/data-model.md new file mode 100644 index 00000000..005e0ad2 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/data-model.md @@ -0,0 +1,130 @@ +# Data Model — Enforce Creation-Time Finding Invariants + +**Spec**: [spec.md](spec.md) + +This feature introduces no new persisted truth. The data-model impact is to make the existing `Finding` lifecycle contract explicit at create, refresh, and reopen time across the three active writer families. + +## Existing Canonical Entities Reused + +### Finding (`findings`) + +**Purpose**: Tenant-owned findings workflow truth. + +**Key fields already in use**: +- `id` +- `workspace_id` +- `tenant_id` +- `finding_type` +- `source` +- `scope_key` +- `fingerprint` +- `recurrence_key` +- `severity` +- `status` +- `first_seen_at` +- `last_seen_at` +- `times_seen` +- `sla_days` +- `due_at` +- `reopened_at` +- `resolved_at` +- `resolved_reason` +- `closed_at` +- `closed_reason` +- `current_operation_run_id` +- `baseline_operation_run_id` + +**Feature use**: +- Remains the single persisted source of truth for active findings lifecycle state. +- Continues to require both `workspace_id` and `tenant_id` anchors. +- Keeps the current status families unchanged. +- Carries the lifecycle-ready fields that this feature hardens at write time. + +### OperationRun (`operation_runs`) + +**Purpose**: Existing execution context for baseline compare and other operational flows. + +**Feature use**: +- Remains contextual only. +- `current_operation_run_id` continues to identify the current writer run where the family already sets it. +- No new operation type or new run-tracking artifact is introduced. + +### StoredReport (`stored_reports`) + +**Purpose**: Existing stored reporting artifact for permission posture output. + +**Feature use**: +- Unchanged. +- Mentioned only because permission posture finding generation already correlates lifecycle-ready findings with an existing report artifact. + +## Derived Non-Persisted Contracts + +### LifecycleReadyFinding (derived contract) + +**Definition**: A `Finding` record that is immediately usable by the existing workflow the moment the active writer persists or refreshes it. + +**Required fields**: +- active canonical status on first create (`new`) +- `first_seen_at` +- `last_seen_at` +- `times_seen >= 1` +- `sla_days` when the current severity policy returns a value +- `due_at` when the current severity policy requires due-date truth +- existing run correlation fields preserved where the writer already populates them + +**Removal rule**: +- no later repair surface may be required for these fields on active writers + +### RecurrenceIdentity (derived contract) + +**Definition**: The family-owned identity that decides whether a repeated observation refreshes one canonical finding or incorrectly creates a duplicate. + +**Family-specific variants**: +- baseline compare: `recurrence_key` and `fingerprint` derived from tenant, baseline profile, policy type, subject key, and change type +- Entra admin roles: existing role-assignment and aggregate fingerprints +- permission posture: existing permission and error fingerprints + +**Guarantee**: +- repeated observation of the same canonical issue reuses one finding identity + +### ObservationBoundary (derived contract) + +**Definition**: The family-specific rule that decides whether `times_seen` should advance. + +**Family-specific variants**: +- baseline compare: same `current_operation_run_id` must not increment `times_seen` twice for the same observation +- Entra admin roles: later `observedAt` advances seen history +- permission posture: later `observedAt` advances seen history + +**Guarantee**: +- retries and repeated processing do not double count the same observation + +## State Transitions Reused + +### Create + +- missing canonical finding identity -> create one `Finding` +- resulting state remains `new` +- lifecycle-ready fields are populated in the same write path + +### Refresh Existing Open Finding + +- existing open finding remains in its current active workflow state +- evidence or severity may refresh according to the writer family +- missing lifecycle-ready fields covered by this feature are repaired inline +- valid existing lifecycle fields should not be needlessly reset + +### Reopen Existing Terminal Finding + +- existing terminal finding transitions through `FindingWorkflowService::reopenBySystem()` +- resulting state becomes `reopened` +- `resolved_*` and `closed_*` markers clear according to the current service behavior +- SLA and due-state truth are recalculated from the later re-observation moment + +## Invariant Rules + +- No new persisted entity, table, or compatibility artifact may be introduced. +- No new workflow status, reopen reason family, or lifecycle label may be introduced. +- Active writers must repair incomplete lifecycle-ready fields inline rather than relying on CLI repair commands, tenant maintenance actions, or deploy-time hooks. +- Due-state repair should fill missing truth or refresh terminal-to-reopened truth only; it must not silently redesign current due-date semantics for already-healthy open findings. +- A later database constraint is a separate follow-up candidate only if application-level write-path enforcement proves insufficient. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/plan.md b/specs/255-enforce-finding-creation-invariants/plan.md new file mode 100644 index 00000000..a90835e1 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/plan.md @@ -0,0 +1,295 @@ +# Implementation Plan: Enforce Creation-Time Finding Invariants + +**Branch**: `255-enforce-finding-creation-invariants` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md` + +**Note**: This plan is prep-only. It updates only spec-package artifacts for implementation readiness and does not change application code, runtime behavior, migrations, assets, or repo files outside this spec directory. + +## Summary + +- Make lifecycle-ready finding creation and recurrence semantics explicit across the only three active finding writers currently persisting `Finding` records: baseline compare drift, Entra admin roles, and permission posture. +- Keep the slice narrow and repo-grounded: reuse existing `Finding` fields, existing recurrence identities, existing `FindingWorkflowService::reopenBySystem()`, and existing `FindingSlaPolicy` behavior; do not add repair tooling, workflow states, migrations, or a broader findings framework. +- Tighten validation where repo proof is already strongest: extend the three focused feature suites so they explicitly cover new creation, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active write paths. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `FindingWorkflowService`, `FindingSlaPolicy`, baseline compare job, Entra admin roles finding generator, and permission posture finding generator +**Storage**: PostgreSQL existing `findings`, `operation_runs`, `stored_reports`, and `audit_logs` only; no new persistence or migration is planned +**Testing**: Pest feature tests in the existing generator and compare suites +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and existing background jobs or services that already generate findings +**Project Type**: web +**Performance Goals**: lifecycle invariants must be satisfied in the same write path that creates or refreshes the finding; no second-pass repair job, no extra operator step, and no widened query surface should be required +**Constraints**: LEAN-001 replacement over compatibility shims; no new persistence; no new workflow states; no compare refresh or repair-tooling scope; preserve existing `404` vs `403` behavior; no new Filament assets, panel work, or provider registration changes +**Scale/Scope**: 3 active finding writer families, 1 shared workflow service, 1 shared SLA policy, 1 existing `Finding` model, and 3 established feature-test families plus downstream findings surfaces as regression consumers only + +## Likely Affected Repo Surfaces + +- Active write paths and their local recurrence or observation logic: + - `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` + - `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` + - `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- Shared lifecycle and due-date seams already reused by those paths: + - `apps/platform/app/Services/Findings/FindingWorkflowService.php` + - `apps/platform/app/Services/Findings/FindingSlaPolicy.php` + - `apps/platform/app/Models/Finding.php` +- Downstream operator-facing regression consumers that should not need design changes but do rely on `due_at`, `reopened_at`, and canonical open-status truth: + - `apps/platform/app/Filament/Resources/FindingResource.php` + - `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` + - `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + - `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` +- Current focused proof surfaces that already cover part of the invariant and should remain the primary validation entry points: + - `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +## Domain / Model Fit + +- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, table, compatibility projection, or lifecycle wrapper is introduced. +- The slice does not change the canonical findings status set. `new`, `triaged`, `in_progress`, and `reopened` remain the active statuses; `resolved`, `closed`, and `risk_accepted` remain terminal statuses. +- Lifecycle-ready creation in this feature means that the first persisted or inline-repaired record is already safe for existing downstream workflow use: canonical active status, `first_seen_at`, `last_seen_at`, `times_seen >= 1`, and existing SLA or `due_at` truth when the current severity policy requires them. +- Recurrence identity stays family-owned and explicit rather than being normalized into a new shared engine: + - baseline compare uses `recurrence_key` plus `fingerprint`, with `current_operation_run_id` preventing double counting for the same compare run + - Entra admin roles uses its existing role-assignment and aggregate fingerprints + - permission posture uses its existing missing-permission and error fingerprints +- `OperationRun` and `StoredReport` remain contextual references only where current writers already use them. This slice does not introduce a new audit artifact or independent lifecycle store. + +## UI / Filament & Livewire Fit + +- No operator-facing surface change is planned. Existing findings resource, inbox, and intake surfaces are regression consumers of better write-time truth, not redesign targets. +- Filament remains v5 on Livewire v4.0+; no Livewire v3 behavior or API is in scope. +- `FindingResource` already has a view page, so the hard global-search rule remains satisfied without new work. No new globally searchable resource is added. +- No destructive action is introduced or changed. Any touched findings action surface must keep current server-side authorization and existing `->requiresConfirmation()` behavior where destructive-like actions already exist. +- No panel/provider work is planned. If provider registration ever became relevant later, Laravel 12 and Filament v5 still require panel providers under `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`. +- No asset change is planned. Deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets. + +## RBAC / Policy Fit + +- This slice should not add a new capability, new role mapping, or new policy branch. User-triggered actions that lead to in-scope finding writes keep their current authorization semantics. +- Tenant membership and workspace membership remain the isolation boundary: non-members stay `404`, in-scope members missing the current capability stay `403`, and no new write bypass is introduced for background or queued generation. +- If implementation appears to require a new capability or policy relaxation just to enforce lifecycle invariants, that is a stop condition and should be split rather than absorbed. + +## Audit / Logging Fit + +- `FindingWorkflowService::reopenBySystem()` remains the authoritative reopen path because it already owns reopened state mutation, audit context, and alert notification dispatch. +- No new `AuditActionId`, no new operation type, and no new completion notification path should be introduced. +- The feature should preserve existing `current_operation_run_id` and `StoredReport` correlation meaning where current writers already set them. Creation-time hardening must not create a second audit or run-tracking dialect. + +## Data / Migration / Constraint Fit + +- No migration, no historical data backfill, no deploy hook, and no repair command are planned. +- Under LEAN-001, stale local data or incomplete fixtures should be handled by fixture replacement or inline repair on active write paths, not by compatibility shims. +- A database-level constraint discussion is allowed only as an explicit follow-up or stop condition if planning or implementation proves that application-level write-path enforcement cannot satisfy the invariant safely. It must not be silently folded into this slice. +- If due-date initialization for already-open findings would require recomputing correct existing data instead of filling missing lifecycle fields only, stop and split rather than broadening this feature into a data repair rollout. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no operator-facing surface change +- **Native vs custom classification summary**: N/A - existing native Filament findings surfaces remain regression consumers only +- **Shared-family relevance**: none; no new notification, header action, dashboard, or evidence viewer family is added +- **State layers in scope**: none +- **Audience modes in scope**: N/A +- **Decision/diagnostic/raw hierarchy plan**: N/A +- **Raw/support gating plan**: N/A +- **One-primary-action / duplicate-truth control**: existing findings workflow actions remain unchanged; tighter write-time truth prevents partial lifecycle data from competing with the existing canonical action flow +- **Handling modes by drift class or surface**: N/A +- **Repository-signal treatment**: review-mandatory for downstream regression only +- **Special surface test profiles**: standard-native-filament regression only +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: no +- **Systems touched**: N/A for shared operator interaction families; domain reuse stays within existing findings lifecycle services only +- **Shared abstractions reused**: existing `FindingWorkflowService` and `FindingSlaPolicy` only +- **New abstraction introduced? why?**: none by default; if a shared write-time normalizer is later proposed, it must be a narrow findings-domain replacement for duplicated inline repair across all three concrete writers, not a new registry or framework +- **Why the existing abstraction was sufficient or insufficient**: `reopenBySystem()` is already sufficient for terminal-to-reopened transitions. The current planning gap is open-record lifecycle repair, which is still duplicated and partially covered across the three writers. +- **Bounded deviation / spread control**: none; keep any repair logic either local to each writer or in one bounded findings-domain helper only if it replaces real duplication immediately + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: existing baseline compare and other generation flows keep their current start and completion UX unchanged +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: existing tenant-owned findings truth only +- **Neutral platform terms / contracts preserved**: existing `Finding` lifecycle and tenant/workspace ownership vocabulary remain unchanged +- **Retained provider-specific semantics and why**: provider-specific recurrence evidence stays inside the existing writer families that already own it +- **Bounded extraction or follow-up path**: N/A + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- LEAN-001: PASS - the slice is explicitly app-code hardening only; no compatibility shim, legacy alias, fallback reader, or migration path is planned. +- TEST-GOV-001: PASS - proof stays in the narrowest existing feature suites, with no browser lane and no new heavy-governance family. +- RBAC-UX: PASS - no new capability or policy branch is introduced; non-members remain `404`, members lacking the current capability remain `403`, and system generation stays tenant-scoped. +- PERSIST-001: PASS - no new persisted truth, table, artifact, or projection is introduced. +- STATE-001: PASS - no new state, reason-code family, or lifecycle branch is added; current findings states remain authoritative. +- PROP-001 / ABSTR-001: PASS - the narrowest plan is to align the three concrete write paths and reuse the existing reopen service. Any helper beyond that is a stop-and-justify decision, not a default. +- XCUT-001 / UI-SEM-001: PASS - no new operator interaction family or presentation framework is introduced. +- Filament v5 / Livewire v4 compliance: PASS - existing findings surfaces stay on native Filament v5 with Livewire v4.0+; no legacy API mixing is planned. +- Global-search hard rule: PASS - `FindingResource` already has a view page, and no new searchable resource is added. +- Panel/provider registration: PASS - no panel/provider work is planned; if needed later, Filament v5 on Laravel 12 still uses `apps/platform/bootstrap/providers.php`. +- Destructive confirmation standard: PASS - no new destructive action is added; existing destructive-like actions remain outside this slice. +- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged. +- Auditability and tenant isolation: PASS - reopen semantics remain on the current audited service path, and every in-scope write remains bound to tenant and workspace context. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature for writer-level creation-time lifecycle readiness, shared recurrence/workflow-service behavior, and narrow downstream consumer plus trigger-authorization continuity checks; no new unit, browser, or heavy-governance family is planned +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature risk lives in domain write behavior already exercised through the existing compare and generator suites, but FR-255-005, FR-255-006, FR-255-009, and FR-255-011 also require bounded proof of shared recurrence/workflow behavior and unchanged consumer/auth continuity. Focused feature coverage is still sufficient because the adjacent checks stay limited to existing findings and trigger-authorization suites. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` +- **Fixture / helper / factory / seed / context cost risks**: low to moderate but bounded; reuse existing tenant, operation-run, snapshot, generator, and trigger-surface fixtures. Avoid a new umbrella findings harness unless repeated setup clearly becomes the bottleneck. +- **Expensive defaults or shared helper growth introduced?**: no; the plan explicitly avoids a new generic invariant framework or new default-heavy helper layer. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native relief; no browser smoke is required because no operator-facing interaction changes are planned +- **Closing validation and reviewer handoff**: rerun the three writer suites plus the bounded recurrence/workflow and consumer/auth suites, confirm each family now proves missing-field inline repair in addition to existing create/idempotence/reopen behavior, and verify that no migration, no policy branch, and no new UI action was introduced while hardening write paths. +- **Budget / baseline / trend follow-up**: none expected beyond routine feature-test maintenance +- **Review-stop questions**: did implementation widen into a repair tool, migration, DB constraint rollout, or generic invariant framework; did it silently reset already-valid due dates; did it leave one writer family with only partial invariant proof +- **Escalation path**: reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: routine lifecycle-hardening proof belongs in this feature unless a database-level constraint or a broader findings lifecycle redesign is proven necessary later + +## Rollout & Risk Controls + +- Rollout is code-only and bounded. No migration, queue worker sequencing, asset build, or provider registration step is expected. +- Recommended implementation order is: + 1. confirm the shared invariant vocabulary and stop conditions against the three active writers only + 2. harden baseline compare first because it already carries the strictest observation-boundary rule through `current_operation_run_id` + 3. align permission posture and Entra admin roles creation and refresh logic around the same lifecycle-ready contract while preserving their family-specific recurrence rules + 4. extract a shared normalizer only if the concrete code shows immediate duplication across all three paths and the helper replaces duplication instead of adding a new abstraction layer + 5. extend focused regression tests and verify downstream findings surfaces do not require design changes +- Stop conditions for task execution: + - another shipped finding writer is discovered outside the three confirmed paths + - the invariant cannot be enforced safely without a migration or DB constraint + - the only available code shape is a new generic registry, strategy system, or lifecycle framework + - user-facing findings workflow affordances would need to change to compensate for missing write-time truth + +## Project Structure + +### Documentation (this feature) + +```text +specs/255-enforce-finding-creation-invariants/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── finding-creation-invariants.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Jobs/ +│ │ └── CompareBaselineToTenantJob.php +│ ├── Models/ +│ │ └── Finding.php +│ ├── Services/ +│ │ ├── EntraAdminRoles/ +│ │ │ └── EntraAdminRolesFindingGenerator.php +│ │ ├── Findings/ +│ │ │ ├── FindingSlaPolicy.php +│ │ │ └── FindingWorkflowService.php +│ │ └── PermissionPosture/ +│ │ └── PermissionPostureFindingGenerator.php +│ └── Filament/ +│ ├── Pages/Findings/ +│ │ ├── FindingsIntakeQueue.php +│ │ └── MyFindingsInbox.php +│ └── Resources/ +│ ├── FindingResource.php +│ └── FindingResource/ +│ └── Pages/ListFindings.php +└── tests/ + └── Feature/ + ├── Baselines/BaselineCompareFindingsTest.php + ├── EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php + ├── Findings/FindingRecurrenceTest.php + ├── Findings/FindingAutomationWorkflowTest.php + ├── Findings/FindingWorkflowServiceTest.php + ├── Findings/MyWorkInboxTest.php + ├── Findings/FindingsIntakeQueueTest.php + ├── Rbac/BaselineCompareMatrixAuthorizationTest.php + ├── EntraAdminRoles/AdminRolesSummaryWidgetTest.php + └── PermissionPosture/PermissionPostureFindingGeneratorTest.php +``` + +**Structure Decision**: Laravel monolith. The implementation should stay inside the existing finding writer services and job, the shared findings lifecycle service and model, and the current focused feature suites rather than creating a new namespace or framework. + +## Complexity Tracking + +No constitution violation is expected. If implementation later proposes a new persistence rule, a new lifecycle framework, or a broad helper layer that serves only speculative future writers, stop and split rather than justifying it inside this slice. + +## Proportionality Review + +N/A - this feature introduces no new enum, presenter, persisted entity, interface, registry, or taxonomy. Any narrow helper extracted during implementation must replace existing duplicated write-time lifecycle normalization immediately across the three confirmed writers or it should not be introduced. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/research.md` + +Goals: +- confirm that the three already-named write paths are still the full active finding-writer inventory in app code +- confirm where current code already repairs lifecycle fields inline and where `sla_days` or `due_at` normalization is still only implied on create or reopen +- document the narrowest shared seam decision: keep repair logic local per writer unless one bounded findings-domain helper clearly replaces real duplication across all three cases +- record the explicit stop condition for any database-level constraint or migration-based enforcement proposal + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/quickstart.md` + +Design focus: +- capture one lifecycle-ready finding contract that all three active writers must satisfy without introducing a new persistence or workflow layer +- keep recurrence identity family-owned while making the create, refresh, and reopen guarantees explicit in one planning contract +- keep downstream Filament findings surfaces, inboxes, and intake queues as regression consumers only; no UI redesign is part of this slice +- document the no-migration, no-constraint-by-default posture and the explicit stop condition for any future constraint follow-up + +## Phase 1 — Agent Context Update + +- Deferred in this prep-only pass because the user explicitly limited edits to this spec directory. +- If maintainers later want full Spec Kit propagation outside the spec package, run: + - `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created later in `/speckit.tasks`) + +- keep the feature bounded to the three confirmed writer paths and the shared reopen service +- align creation-time lifecycle initialization and open-record inline repair in `CompareBaselineToTenantJob`, `EntraAdminRolesFindingGenerator`, and `PermissionPostureFindingGenerator` +- preserve family-specific recurrence and observation-boundary behavior while making it explicit in code and tests +- preserve `FindingWorkflowService::reopenBySystem()` as the only reopened-state mutation path +- extend the three focused feature suites so each family proves creation readiness, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active paths +- verify that no migration, no new capability, no new workflow state, no repair surface, and no operator-facing workflow expansion slipped into the implementation slice + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape remains prep-only, introduces no new persistence or state family, keeps Filament on Livewire v4.0+, leaves provider registration unchanged in `apps/platform/bootstrap/providers.php`, keeps global search unchanged through the existing `FindingResource` view page, leaves destructive actions untouched, and keeps the proving burden inside the three existing focused feature suites unless a bounded stop condition forces a split. +- **Ownership cost created**: focused ongoing maintenance in the three writer suites plus bounded shared recurrence/workflow and trigger-authorization regressions; no migration, framework, or new persistence cost is added. +- **Alternative intentionally rejected**: a generic invariant framework, a new repair or backfill path, and any DB-constraint rollout were rejected because the repo currently has three concrete writers and current-release truth only requires tightening those exact paths. +- **Release truth**: current-release truth. This package hardens already-shipped finding writers rather than preparing speculative future families. diff --git a/specs/255-enforce-finding-creation-invariants/quickstart.md b/specs/255-enforce-finding-creation-invariants/quickstart.md new file mode 100644 index 00000000..6a91f56b --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart — Enforce Creation-Time Finding Invariants + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing compare and generator feature fixtures available +- Existing tenant/workspace helpers available for targeted findings tests + +## Run locally after implementation + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- Use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Run the focused validation suites for this slice: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` +- Format any implementation changes: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +The two additional commands are the only bounded adjacent proof beyond the three writer suites. They cover shared recurrence/workflow semantics plus unchanged downstream consumer and trigger-authorization contracts. + +## Manual smoke after implementation + +1. Trigger one baseline compare drift finding and confirm the newly created record appears immediately usable on `/admin/t/{tenant}/findings`, including due-state and seen-history cues where current UI already renders them. +2. Trigger one permission posture and one Entra admin roles finding and confirm the first persisted record has the expected lifecycle-ready fields without any maintenance action. +3. Resolve an in-scope finding, re-observe the same issue, and confirm the same finding identity reopens with refreshed due or SLA truth and existing history retained. +4. Re-run the same baseline compare operation identity and confirm `times_seen` does not double count on retry. +5. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new repair surface, capability, or operator-facing workflow branch was introduced. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; this feature does not add or redesign an operator-facing Filament surface. +- `FindingResource` already has a view page, so there is no new global-search compliance work. +- No new destructive action is planned; existing destructive-like findings actions stay outside this slice and keep their current confirmation and authorization behavior. +- No panel or provider change is planned; `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location for Filament work in Laravel 12. +- No asset change is expected, so there is no additional `filament:assets` deployment work for this slice. +- This prep package intentionally leaves repo-wide agent-context regeneration outside scope so changes stay inside `specs/255-enforce-finding-creation-invariants/` only. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/research.md b/specs/255-enforce-finding-creation-invariants/research.md new file mode 100644 index 00000000..c7a0e857 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/research.md @@ -0,0 +1,126 @@ +# Research — Enforce Creation-Time Finding Invariants + +**Date**: 2026-04-29 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded planning decisions for the creation-time findings hardening slice after Specs 253 and 254. All decisions assume the current pre-production LEAN-001 posture. + +## Decision 1 — Scope the feature to the three active finding writers that currently persist `Finding` records + +**Decision**: Treat baseline compare drift, Entra admin roles, and permission posture as the full active writer set for this feature. + +**Rationale**: +- Repo search shows only five direct `Finding` creation sites in app code: one `new Finding` path in `CompareBaselineToTenantJob` and four `Finding::create()` sites split between Entra admin roles and permission posture. +- No other shipped service or job currently persists `Finding` records directly, so widening the slice would be speculative rather than repo-driven. +- This keeps the hardening aligned with the spec's stated bounded scope and avoids inventing a new writer registry. + +**Evidence**: +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + +**Alternatives considered**: +- Widen to every findings consumer or downstream summary surface. + - Rejected: those surfaces consume findings truth but do not create it. +- Add a speculative "all writers" registry now. + - Rejected: violates ABSTR-001 because three concrete paths are already directly visible. + +## Decision 2 — Enforce lifecycle readiness in the same write path, not through a later repair pass + +**Decision**: Require each in-scope writer to create or refresh lifecycle-ready findings inside the same code path that persists or updates the record. + +**Rationale**: +- Spec 253 removes runtime backfill surfaces and this feature explicitly exists to prevent reintroducing that repair dependency. +- Current code already initializes lifecycle fields on new creates and updates some fields inline on repeated observations; that makes write-path hardening the narrowest correct implementation. +- Downstream findings pages, inboxes, and intake queues already assume findings are ready for immediate use. + +**Evidence**: +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Filament/Resources/FindingResource.php` +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + +**Alternatives considered**: +- Reintroduce a maintenance action or backfill command. + - Rejected: directly conflicts with the cleanup direction from Spec 253. +- Add a deploy-time or queue-time repair hook. + - Rejected: widens scope and hides invariant ownership. + +## Decision 3 — Preserve `FindingWorkflowService::reopenBySystem()` as the only shared reopen path + +**Decision**: Keep terminal-to-reopened mutation on `FindingWorkflowService::reopenBySystem()` and treat open-record lifecycle normalization as the actual planning gap. + +**Rationale**: +- `reopenBySystem()` already validates terminal-status eligibility, recalculates SLA or due state, clears resolved or closed markers, writes audit context, and dispatches the reopened alert notification. +- Bypassing it would create a second reopen dialect and risk inconsistent audit or notification semantics. +- The repo gap is not reopened-state ownership; it is that current open-record repair is still distributed across per-family `observeFinding()` logic and currently emphasizes seen-history more than full lifecycle readiness. + +**Evidence**: +- `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + +**Alternatives considered**: +- Reopen findings directly inside each writer. + - Rejected: duplicates side effects and weakens audit consistency. +- Create a new generic lifecycle orchestration framework. + - Rejected: too broad for three known writers. + +## Decision 4 — Keep recurrence identity family-owned and preserve each writer's current double-count boundary + +**Decision**: Keep the existing recurrence identity and observation boundary per family instead of forcing one synthetic cross-domain algorithm. + +**Rationale**: +- Baseline compare already uses `recurrence_key` plus `fingerprint` with `current_operation_run_id` to suppress duplicate `times_seen` increments for the same compare run. +- Entra admin roles and permission posture use later `observedAt` comparisons to advance seen history. +- The operator need is one canonical finding identity per issue family, not one universal recurrence engine. + +**Evidence**: +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + +**Alternatives considered**: +- Normalize all writers onto a single recurrence service. + - Rejected: would add abstraction without current-release need. +- Count every repeated observation the same way across all writers. + - Rejected: risks breaking baseline retry semantics. + +## Decision 5 — The current proof gap is inline repair of incomplete lifecycle fields on existing findings + +**Decision**: Plan for explicit regression proof that existing open findings with missing lifecycle fields are repaired inline on active paths, especially for `sla_days` and `due_at`. + +**Rationale**: +- Existing tests already prove creation readiness, idempotence, and reopen behavior in all three families. +- Repo code also already repairs `first_seen_at`, `last_seen_at`, and `times_seen` inline when existing findings are re-observed. +- What is not yet clearly owned as one invariant is the repair of incomplete lifecycle fields such as missing due-state data on existing findings encountered through active writers. + +**Evidence**: +- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +**Alternatives considered**: +- Rely on current creation and reopen tests only. + - Rejected: leaves FR-255-007 partially implied. +- Add a new browser or broad workflow suite. + - Rejected: too expensive for a write-path invariant gap. + +## Decision 6 — Keep schema and DB constraints out of the slice unless they become an explicit stop condition + +**Decision**: Keep the default plan app-code-only. Any database-level constraint or migration-based enforcement is a bounded follow-up candidate or an explicit stop condition, not part of this feature by default. + +**Rationale**: +- The repo is pre-production and LEAN-001 favors direct replacement over compatibility layers. +- The current code already has the necessary domain seams to harden write-time behavior without changing the schema. +- Folding a constraint into this feature would silently broaden it from write-path hardening into data rollout and compatibility review. + +**Evidence**: +- `.specify/memory/constitution.md` +- `specs/255-enforce-finding-creation-invariants/spec.md` + +**Alternatives considered**: +- Add `NOT NULL` or check constraints now. + - Rejected: outside the smallest bounded slice. +- Keep the option undefined. + - Rejected: the plan must name the stop condition explicitly so task generation stays bounded. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/spec.md b/specs/255-enforce-finding-creation-invariants/spec.md new file mode 100644 index 00000000..b36b3c75 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/spec.md @@ -0,0 +1,280 @@ +# Feature Specification: Enforce Creation-Time Finding Invariants + +**Feature Branch**: `255-enforce-finding-creation-invariants` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "Prepare only the spec artifacts for `Enforce Creation-Time Finding Invariants` on the existing 255 branch as the next bounded findings data-integrity slice after Specs 253 and 254." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Findings that reach operators through active generators already tend to look lifecycle-ready, but that truth is still implied and distributed. If a new or changed generator path omits status, seen timestamps, seen count, or due/SLA initialization, operators can receive findings that look real before they are workflow-ready. +- **Today's failure**: TenantPilot would have to rely on scattered implicit behavior or future repair logic to make a new or recurring finding usable. That weakens trust in due state, recurrence history, and reopen behavior right at the moment an operator is asked to act. +- **User-visible improvement**: Newly created or reopened findings arrive already ready for existing workflow use, with stable identity and lifecycle metadata that operators can trust immediately. +- **Smallest enterprise-capable version**: Make creation-time and recurrence-time finding invariants explicit for the active generator families and their shared reopen semantics, backed by focused regression proof, while reusing existing finding fields and workflow states only. +- **Explicit non-goals**: No backfill runtime surfaces, no acknowledged cleanup, no new customer-facing workflow, no broader findings lifecycle redesign, no new persistence, no new states, no external integration, no owner/assignee mandate, and no schema rollout except a possible future narrow follow-up. +- **Permanent complexity imported**: Low and bounded. The feature should add only explicit invariant coverage and possibly a narrow shared write-time guard if planning proves it necessary; no new table, state family, framework, or operator surface is justified. +- **Why now**: Specs 253 and 254 remove adjacent repair and compatibility debt. The next bounded unspecced findings candidate is to lock in the post-cleanup target state so active generators cannot drift back into repair-tool dependency. +- **Why not local**: Repo truth spans baseline compare, Entra admin roles, permission posture, shared reopen behavior, SLA/due initialization, and recurrence semantics. A local fix in one generator would leave the others as implied behavior and keep the invariant unowned. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this is the final bounded data-integrity hardening slice after surface removal and acknowledged cleanup, and it explicitly avoids bundling broader lifecycle redesign or new infrastructure. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Selection Rationale + +- The source candidate is the active P1 entry `Enforce Creation-Time Finding Invariants` in `docs/product/spec-candidates.md`. +- This slice sits in the Findings Workflow / Data Integrity sequence and follows Spec 253 (`Remove Findings Lifecycle Backfill Runtime Surfaces`) and Spec 254 (`Remove Legacy Acknowledged Finding Status Compatibility`). +- It is the next bounded unspecced candidate in repo order. Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Platform Localization, Remove Findings Lifecycle Backfill Runtime Surfaces, and Remove Legacy Acknowledged Finding Status Compatibility already have specs. +- `External Support Desk / PSA Handoff` remains blocked because the repo still does not name one concrete external desk or PSA target. +- `Cross-Tenant Compare and Promotion v1` already has Spec 043 and is a refresh candidate, not the next unspecced preparation target. +- The smallest viable slice is to prove that active finding generators and reopen/recurrence paths always create or refresh findings in a lifecycle-ready state at write time, without reintroducing repair tooling, redesigning the lifecycle, adding new persistence, adding new workflow states, or widening into external workflow work. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant +- **Primary Routes**: + - No new or changed direct route is the product target. + - Existing tenant findings surfaces are downstream regression consumers only: `/admin/t/{tenant}/findings` and `/admin/t/{tenant}/findings/{record}`. + - In-scope behavior is reached through existing tenant-scoped finding generation paths, including baseline compare completion, Entra admin role finding generation, and permission posture finding generation. +- **Data Ownership**: + - Tenant-owned `Finding` records remain the canonical truth and keep required `workspace_id` plus `tenant_id` anchors. + - Existing `OperationRun` and `StoredReport` references stay contextual only where the current generators already use them; this feature introduces no new persisted entity, mirror table, or compatibility artifact. + - The scope is limited to write-time creation and refresh behavior for existing finding truth. +- **RBAC**: + - Tenant membership remains the isolation boundary for the downstream findings surfaces that consume these records. + - Existing user-triggered paths that lead to in-scope finding creation remain capability-first; non-members stay 404 and members lacking the current capability stay 403. + - Background or system-triggered generation must preserve tenant/workspace isolation and must not create a bypass that can write findings outside the current tenant scope. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: no +- **Interaction class(es)**: N/A - no shared operator interaction family is added or changed +- **Systems touched**: N/A - operator-facing shared interaction patterns stay unchanged +- **Existing pattern(s) to extend**: none +- **Shared contract / presenter / builder / renderer to reuse**: none +- **Why the existing shared path is sufficient or insufficient**: This slice hardens tenant-owned finding writes and shared lifecycle semantics, not notifications, action surfaces, or dashboard presentation. +- **Allowed deviation and why**: none +- **Consistency impact**: downstream findings and review surfaces continue consuming the same finding truth without any new UI branch +- **Review focus**: reviewers should verify that the feature stays in write-time lifecycle hardening and does not smuggle in new operator interaction patterns + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: N/A +- **Delegated start/completion UX behaviors**: N/A +- **Local surface-owned behavior that remains**: existing baseline compare and other current generation flows keep their current launch, completion, and link UX; this slice only hardens the finding writes they already produce +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception required?**: none + +## 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?**: no +- **Boundary classification**: N/A +- **Seams affected**: N/A +- **Neutral platform terms preserved or introduced**: N/A +- **Provider-specific semantics retained and why**: N/A +- **Why this does not deepen provider coupling accidentally**: The slice hardens existing tenant-owned finding lifecycle truth across already-active generators without introducing a new shared provider seam, taxonomy, or vocabulary. +- **Follow-up path**: none + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change. Existing findings, review, and summary surfaces are regression consumers of better write-time truth, not redesign targets in this feature. + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators should not receive a newly created or reopened finding that still depends on an implicit later repair step before due state, seen history, or canonical workflow use is trustworthy. +- **Existing structure is insufficient because**: the write-time invariant exists only as distributed repo behavior today. It is partially proven in separate tests, but not yet owned as one explicit product hardening slice across the active generator families and their recurrence semantics. +- **Narrowest correct implementation**: make the invariant explicit across the verified active generator families and shared reopen/recurrence behavior, using the existing finding fields, existing workflow states, existing SLA policy, and focused regression proof only. +- **Ownership cost**: a small amount of enduring regression coverage and possibly a narrow shared write-time guard if planning proves it necessary. No new table, state family, or general framework is justified. +- **Alternative intentionally rejected**: reintroducing lifecycle backfill or repair tooling, adding a new invariant framework or persistence layer, or widening into a broader findings lifecycle redesign. +- **Release truth**: current-release truth + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: the behavioral proof stays centered on the three focused writer suites around baseline compare, Entra admin roles, and permission posture, with only bounded adjacent regression in shared recurrence/workflow-service and downstream consumer/auth continuity tests because FR-255-005, FR-255-006, FR-255-009, and FR-255-011 cross the writer boundaries. +- **New or expanded test families**: none by default; reuse and tighten the three focused writer suites, plus bounded regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` only where they prove shared recurrence, consumer honesty, or unchanged trigger authorization +- **Fixture / helper cost impact**: low and near-neutral. The default path should reuse existing tenant, finding, and operation helpers instead of adding a broader harness. +- **Heavy-family visibility / justification**: none. No new heavy-governance or browser family is justified for this slice. +- **Special surface test profile**: N/A +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient because this slice hardens domain truth behind existing workflows rather than adding a new UI surface +- **Reviewer handoff**: reviewers must confirm that the final proof covers new finding creation, repeated observation, resolved-to-reopened transitions, unchanged 404 versus 403 semantics on the existing trigger surfaces, and preserved `current_operation_run_id` meaning without expanding into unrelated workflow or UI coverage +- **Budget / baseline / trend impact**: none expected beyond ordinary focused feature-test upkeep +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` + +## RBAC / Isolation Considerations + +- Tenant-owned findings remain scoped by `workspace_id` and `tenant_id`. The feature must not create or preserve tenantless finding truth. +- Existing user-triggered operations that can lead to the in-scope finding writes keep current capability-first authorization. This slice does not add a new capability or role alias. +- Downstream findings and review surfaces keep current deny-as-not-found versus forbidden behavior: non-members remain 404, in-scope members missing the existing capability remain 403 on triggering actions. +- Explicit 404 versus 403 continuity proof stays bounded to `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`, because permission posture finding generation is background-triggered rather than launched from a separate tenant UI action. +- System-initiated reopen or refresh behavior stays inside the current tenant/workspace context and must not widen read or write visibility across tenants. + +## Auditability + +- Existing workflow-driven reopen semantics remain authoritative for system reopen behavior. The feature must preserve current audit and workflow meaning instead of introducing a silent side path. +- Existing `current_operation_run_id` correlations stay in place where the current generators already populate them; this slice does not add a second run-correlation path or new audit artifact. +- The hardening must not allow partially initialized findings to look settled or complete on downstream operator surfaces. The audit trail should continue to explain system-created and system-reopened findings through the existing lifecycle paths. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Ready Findings Immediately (Priority: P1) + +As a tenant operator, I want a newly detected finding to arrive already ready for the existing findings workflow so I can trust the status, due state, and seen history the first time it appears. + +**Why this priority**: This is the core product-truth outcome. If new findings still depend on implied repair logic, the feature has failed. + +**Independent Test**: Can be fully tested by triggering one new finding in each in-scope generator family and verifying that the first persisted record is already lifecycle-ready before any downstream findings page or review surface consumes it. + +**Acceptance Scenarios**: + +1. **Given** a baseline compare run detects new drift for a tenant, **When** the finding is first written, **Then** it already carries the canonical open status, first seen and last seen timestamps, seen count, and the due or SLA data required by the existing workflow. +2. **Given** an Entra admin roles or permission posture run detects a new issue, **When** the tenant findings register later displays that record, **Then** no backfill, repair action, or second pass is required to make the finding usable in the existing workflow. + +--- + +### User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1) + +As a tenant operator, I want a resolved issue that reappears to reopen the same finding with fresh lifecycle truth so I can continue work with the existing history instead of receiving a duplicate or stale record. + +**Why this priority**: Reopen behavior is the critical recurrence path that keeps findings trustworthy after the cleanup sequence in Specs 253 and 254. + +**Independent Test**: Can be fully tested by resolving an in-scope finding, observing the same issue again, and verifying that the existing finding reopens with refreshed lifecycle fields. + +**Acceptance Scenarios**: + +1. **Given** a previously resolved baseline drift finding reappears, **When** the same drift is observed again, **Then** the existing finding reopens, resolved markers clear as needed, and the lifecycle fields required for current workflow use are refreshed at write time. +2. **Given** a previously resolved Entra admin roles or permission posture finding reappears, **When** the generator sees the same active issue again, **Then** the system reopens the same finding identity and refreshes the due or SLA truth according to the current severity policy already in use. + +--- + +### User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2) + +As a tenant operator, I want repeated detection of the same active issue to strengthen the same finding record instead of creating uncontrolled duplicates or inflating seen counts incorrectly. + +**Why this priority**: Stable recurrence semantics protect operator trust in counts, history, and due attention without widening the feature into broader lifecycle redesign. + +**Independent Test**: Can be fully tested by retrying or repeating the same observation across the in-scope families and verifying one canonical finding identity with bounded seen-count updates. + +**Acceptance Scenarios**: + +1. **Given** the same canonical issue is retried under the same observation identity, **When** the generator processes it again, **Then** the system does not create a duplicate finding and does not double-count the same observation. +2. **Given** the same canonical issue is observed again under a later valid observation, **When** the generator refreshes the existing finding, **Then** the same finding identity remains in place and the seen history advances according to that family's existing recurrence semantics. + +### Edge Cases + +- A retried baseline compare job using the same run identity must not increment `times_seen` twice for the same observation. +- An existing finding encountered on a normal active path may still be missing `first_seen_at`, `last_seen_at`, or `times_seen`; the in-scope write path must repair those fields inline instead of depending on a separate repair surface. +- A resolved finding should reopen only when the new observation is later than the prior resolution boundary; out-of-order or stale observations must not incorrectly reopen it. +- If current SLA policy derives a due date from severity, the reopened or newly created record must be ready for that downstream truth immediately; the feature must not defer due-state initialization to a later process. +- The feature must preserve one canonical finding identity even when evidence payloads or current hashes change between observations. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes tenant-owned finding write behavior but does not add Microsoft Graph calls, a new user-facing mutation surface, or a new long-running workflow. It hardens existing write-time semantics in current generator paths, preserves tenant isolation, preserves existing audit meaning plus `current_operation_run_id` correlation on the in-scope write paths, and requires focused regression proof. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice must not introduce new persistence, new abstraction, new state family, or new semantic layer. If planning proposes a shared invariant helper, it must prove why the existing distributed write paths cannot safely stay explicit without creating a new unowned drift point. + +**Constitution alignment (XCUT-001):** No new cross-cutting operator interaction family is allowed in this slice. Existing findings, review, and summary surfaces remain unchanged consumers of better write-time truth. + +**Constitution alignment (TEST-GOV-001):** Proof stays in the narrow focused feature tests already closest to the active generator families and their recurrence behavior. The feature must not create a new heavy family or browser dependency. + +**Constitution alignment (OPS-UX / OPS-UX-START-001):** Existing baseline compare and other current generation flows may continue using their current `OperationRun` semantics where already present, but this feature does not add or change operation start UX, queued notification policy, or deep-link behavior. + +**Constitution alignment (RBAC-UX):** Existing triggering authorization stays capability-first and unchanged. The feature must not add a hidden bypass or new capability branch to create or reopen findings. + +**Constitution alignment (OPSURF-001 / DECIDE-AUD-001):** Existing operator surfaces must never depend on partially initialized finding truth. The hardening exists so downstream decision surfaces continue to show calm, honest workflow data without false readiness. + +### Functional Requirements + +- **FR-255-001**: The system MUST ensure each in-scope active finding generator family writes a newly created finding in a lifecycle-ready state within the same write path that first persists the record. +- **FR-255-002**: The in-scope active generator families for this feature are baseline compare drift, Entra admin roles, and permission posture. The invariant MUST be explicit across all three families, not only one local path. +- **FR-255-003**: A newly created in-scope finding MUST carry the canonical initial workflow status plus the lifecycle fields needed by existing downstream workflow surfaces, including first seen and last seen timestamps, a valid seen count, and existing SLA or due-date truth when the current severity policy already requires them. +- **FR-255-004**: Repeated observation of the same active condition MUST reuse one canonical finding identity through the existing recurrence key or fingerprint semantics and MUST refresh the existing record instead of creating uncontrolled canonical duplicates. +- **FR-255-005**: A retry or repeated processing of the same observation identity MUST NOT double-count the same observation. Each in-scope generator family may keep its current observation semantics, but the feature MUST make those semantics explicit and regression-protected. +- **FR-255-006**: When a previously resolved in-scope finding reappears, the system MUST reopen the existing finding through the current workflow path and MUST clear or refresh the lifecycle data required for immediate downstream workflow use. +- **FR-255-007**: If an in-scope active path encounters an existing finding with missing lifecycle fields covered by this slice, the normal write path MUST repair those fields inline instead of depending on backfill jobs, tenant repair actions, CLI repair commands, or deploy-time hooks. +- **FR-255-008**: The feature MUST preserve current tenant/workspace isolation by keeping every in-scope finding write anchored to the current tenant and workspace and by not widening visibility or write scope across tenants. +- **FR-255-009**: The feature MUST preserve capability-first RBAC and existing 404 versus 403 semantics on the current user-triggered entry points that lead to in-scope finding creation or refresh, specifically the baseline compare matrix and admin-roles scan surfaces. +- **FR-255-010**: The feature MUST preserve existing finding workflow states, downstream review surfaces, and operator affordances. It MUST NOT add new workflow states, reintroduce repair tooling, re-open acknowledged-status cleanup, require owner or assignee fields, or add external support or PSA workflow scope. +- **FR-255-011**: The feature MUST keep existing audit meaning and `current_operation_run_id` correlation intact where the current generators already attach reopened or refreshed findings to system workflow paths. +- **FR-255-012**: Regression proof MUST make the invariant explicit across new creation, repeated observation, and resolved-to-reopened behavior for the in-scope generator families. +- **FR-255-013**: Any database constraint or migration-based invariant enforcement beyond the existing application write paths is out of scope for this feature and MAY only be considered as a later narrow follow-up if planning proves it is compatibility-safe and materially smaller than a broader redesign. + +### Key Entities *(include if feature involves data)* + +- **Lifecycle-ready finding**: A tenant-owned finding record that is immediately usable in the existing workflow because it already has canonical lifecycle status, seen history, recurrence identity, and due/SLA truth where current policy requires it. +- **Finding generator family**: One of the active repo-owned write paths that creates or refreshes findings today: baseline compare drift, Entra admin roles, or permission posture. +- **Recurrence identity**: The existing recurrence key or fingerprint semantics that decide whether repeated observation refreshes one finding or incorrectly creates a new one. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: During regression validation, 100% of newly created in-scope findings arrive with the lifecycle data needed by the existing downstream workflow in the same observation cycle that first persists them. +- **SC-002**: During regression validation, 0 in-scope active finding paths require a separate repair, backfill, or deploy-time step before newly created or reopened findings are safe to show on existing workflow surfaces. +- **SC-003**: During regression validation, repeated observation of the same in-scope issue reuses one canonical finding identity instead of creating uncontrolled duplicates across each in-scope generator family. +- **SC-004**: During regression validation, previously resolved in-scope findings reopen through the existing workflow path with refreshed lifecycle truth across each in-scope generator family. + +## Dependencies + +- The baseline compare finding creation and recurrence path in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- The Entra admin roles finding creation and reopen path in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- The permission posture finding creation and reopen path in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- Existing shared finding lifecycle behavior such as reopen semantics and SLA/due calculation already used by those paths +- Existing focused regression proof in: + - `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +## Assumptions + +- Spec 253 removes the visible lifecycle backfill runtime surfaces and Spec 254 removes acknowledged compatibility first, so this slice can focus only on the post-cleanup target state. +- The three verified active generator families above are the full bounded scope for this feature unless planning finds another currently active finding writer that is equally first-class and already shipping. +- Lifecycle-ready does not make owner, assignee, or additional governance fields mandatory. It only covers the existing lifecycle truth needed for current workflow readiness. +- The product remains pre-production, so historical data migration, compatibility shims, and retained repair tooling are not justified. +- Downstream findings, review, and summary surfaces should continue working without design changes if write-time truth is hardened correctly. + +## Risks + +- Another active finding writer may exist outside the three verified families and remain unsafely implicit if planning does not confirm the full set before implementation. +- Over-eager implementation could introduce a generic invariant framework or broaden into lifecycle redesign, which would violate the intended slice boundary. +- Different generator families already count repeated observation differently; forcing one artificial rule instead of preserving each family's valid observation semantics could create regressions while trying to harden the invariant. + +## Out of Scope + +- Reintroducing findings lifecycle backfill runtime surfaces, repair commands, deploy hooks, or tenant repair actions +- Removing acknowledged compatibility or changing broader findings workflow vocabulary, which is already covered by Spec 254 +- New customer-facing workflow surfaces, review inbox redesign, customer review workspace work, or localization work +- New persistence, new workflow states, new owner/assignee requirements, or broader findings lifecycle redesign +- External Support Desk / PSA Handoff work +- Cross-Tenant Compare and Promotion refresh work already tracked under Spec 043 +- Schema changes, migrations, or database constraints except as an explicit later follow-up candidate + +## Follow-up Candidates + +1. A very narrow database-level invariant guard may be considered later only if planning proves it can enforce one of these fields safely without reopening compatibility or widening the feature. +2. `External Support Desk / PSA Handoff` remains deferred until the repo names one concrete external desk or PSA target. +3. `Cross-Tenant Compare and Promotion v1` remains on the existing Spec 043 track as a refresh candidate rather than being reopened inside this hardening slice. diff --git a/specs/255-enforce-finding-creation-invariants/tasks.md b/specs/255-enforce-finding-creation-invariants/tasks.md new file mode 100644 index 00000000..0e995f59 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/tasks.md @@ -0,0 +1,242 @@ +# Tasks: Enforce Creation-Time Finding Invariants + +**Input**: Design documents from `/specs/255-enforce-finding-creation-invariants/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/finding-creation-invariants.contract.yaml`, `checklists/requirements.md` + +**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md`. Keep the three writer suites as the primary proof in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`. Use only bounded adjacent regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` where they prove shared recurrence, consumer honesty, or unchanged trigger authorization without inflating the implementation scope into direct UI rewrites. +**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migrations, DB constraints, repair tooling, deploy hooks, or external integrations. Existing `current_operation_run_id` correlations stay contextual only where the current writers already set them, and any report-emission assertions remain bounded to the writer suites that already own them. +**RBAC**: Preserve current tenant/workspace isolation, current `404` versus `403` behavior on the baseline compare matrix and admin-roles scan trigger surfaces, and the existing tenant-scoped background/system reopen semantics. Do not add a new capability, bypass, or customer-facing workflow branch. +**UI / Surface Guardrails**: This is a `review-mandatory` write-time truth hardening slice with `standard-native-filament` relief. `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` stay regression consumers only unless existing tests prove a shared-truth fix is insufficient. +**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, provider, or asset work is introduced. `FindingResource` already has a view page, so global-search compliance stays satisfied without new tasking. No new destructive action is introduced or changed. +**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` -> `US2` -> `US3` -> final validation, because creation-readiness must be explicit before reopen and recurrence proofs are tightened. + +**Implementation note**: If creation-time invariants converge through the three writer paths plus `FindingWorkflowService` and `FindingSlaPolicy`, keep downstream findings surfaces untouched and make proof responsibility explicit in their existing test files rather than planning direct edits to every listed consumer file. + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for write-time lifecycle hardening. +- [x] New or changed tests stay in focused `Feature` files only; no browser or new heavy-governance family is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; any broader setup is isolated to the findings suites that already need it. +- [x] Planned validation commands stay limited to the quickstart command set, allowing the three writer-suite commands to be combined into one equivalent Sail invocation plus the shared recurrence, consumer, and trigger-authorization checks below. +- [x] The declared surface test profile stays `standard-native-filament`; downstream findings surfaces remain proof consumers only. +- [x] Any material residue or follow-up note resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`, not as implicit scope drift. + +## Phase 1: Setup (Shared Invariant Anchors) + +**Purpose**: Lock the bounded writer inventory, shared lifecycle seams, and proving commands before implementation starts. + +- [x] T001 [P] Verify the bounded feature package, stop conditions, and non-goals across `specs/255-enforce-finding-creation-invariants/spec.md`, `specs/255-enforce-finding-creation-invariants/plan.md`, `specs/255-enforce-finding-creation-invariants/research.md`, `specs/255-enforce-finding-creation-invariants/data-model.md`, `specs/255-enforce-finding-creation-invariants/quickstart.md`, and `specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml` +- [x] T002 [P] Verify the active finding-writer and shared seam inventory across `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and `apps/platform/app/Models/Finding.php` +- [x] T003 [P] Verify the narrow Sail validation commands and manual smoke expectations in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md` +- [x] T004 [P] Verify downstream proof-only consumers across `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` + +**Checkpoint**: The bounded invariant target, shared seams, and validation entry points are explicit before any runtime file changes begin. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Make the intended proof surfaces and adjacent cleanup guardrails explicit before the write paths are changed. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T005 [P] Lock the per-family lifecycle-ready and inline-repair proof plan in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T006 [P] Lock the shared recurrence and reopen proof plan in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [x] T007 [P] Audit incomplete-lifecycle fixture and helper anchors across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T008 [P] Audit adjacent cleanup guardrails in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` so this slice does not reintroduce repair tooling or acknowledged compatibility + +**Checkpoint**: Writer-level proof, shared reopen proof, and adjacent no-regression guardrails are explicit and ready for bounded implementation work. + +--- + +## Phase 3: User Story 1 - See Ready Findings Immediately (Priority: P1) + +**Goal**: Newly detected findings arrive lifecycle-ready on first persistence across the three active writer families. + +**Independent Test**: Trigger one new finding per writer family and verify the first persisted record already carries canonical open status, seen history, ownership anchors, and due or SLA truth without any repair or second-pass workflow. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Extend baseline compare create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [x] T010 [P] [US1] Extend Entra admin roles create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- [x] T011 [P] [US1] Extend permission posture create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +### Implementation for User Story 1 + +- [x] T012 [US1] Align baseline compare finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- [x] T013 [US1] Align Entra admin roles finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- [x] T014 [US1] Align permission posture finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [x] T015 [US1] Keep ownership anchors plus due or SLA initialization explicit without introducing a migration or repair surface in `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and the three story tests from `T009` through `T011` + +**Checkpoint**: User Story 1 is independently functional and all three active writers create lifecycle-ready findings in the same write path. + +--- + +## Phase 4: User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1) + +**Goal**: Resolved findings reopen through the existing shared workflow path with refreshed lifecycle truth and preserved canonical identity. + +**Independent Test**: Resolve an in-scope finding, re-observe the same issue through each writer family, and verify the same record reopens with cleared terminal markers and refreshed due or SLA truth. + +### Tests for User Story 2 + +- [x] T016 [P] [US2] Add baseline compare resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [x] T017 [P] [US2] Add Entra admin roles resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- [x] T018 [P] [US2] Add permission posture resolved-to-reopened regression plus existing `current_operation_run_id` and stored-report emission continuity coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T019 [P] [US2] Tighten shared reopen-service proof for `reopenBySystem()` due or SLA recalculation, audit continuity, and terminal eligibility in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` + +### Implementation for User Story 2 + +- [x] T020 [US2] Keep reopened-state mutation on `FindingWorkflowService::reopenBySystem()` and reconcile baseline compare call sites in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- [x] T021 [US2] Preserve same-finding reopen identity and family-specific evidence refresh in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [x] T022 [US2] Reconcile reopened due-date, SLA, and resolved-marker expectations without adding new workflow states or audit dialects in `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` + +**Checkpoint**: User Story 2 is independently functional and resolved findings reopen through the existing shared workflow semantics rather than duplicating records or adding a second reopen path. + +--- + +## Phase 5: User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2) + +**Goal**: Repeated observation strengthens the same finding record, respects each family's observation boundary, and keeps downstream surfaces truthful without widening the feature into UI redesign. + +**Independent Test**: Re-run the same observation and then a later valid observation across the in-scope families and verify that one canonical finding identity remains in place, same-observation retries do not double count, and downstream findings surfaces still read honest lifecycle truth. + +### Tests for User Story 3 + +- [x] T023 [P] [US3] Extend same-observation idempotence and canonical-identity reuse coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` +- [x] T024 [P] [US3] Extend cross-family recurrence and observation-boundary coverage in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` and `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php` +- [x] T025 [P] [US3] Tighten downstream consumer and trigger-authorization proof that shared lifecycle truth still renders honestly and that non-members remain `404` while in-scope capability failures remain `403` in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` + +### Implementation for User Story 3 + +- [x] T026 [US3] Preserve family-owned recurrence keys, fingerprints, and observation boundaries while preventing duplicate canonical findings in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [x] T027 [US3] Keep any shared lifecycle normalization bounded to `apps/platform/app/Services/Findings/` only when it replaces real duplication across all three writers, with proof confined to `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, and `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` rather than widening into new workflow or UI files + +**Checkpoint**: User Story 3 is independently functional and recurrence keeps one canonical finding identity without double-counting or forcing direct downstream surface rewrites. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Keep the slice bounded, run the narrow validation workflow, and check for out-of-scope residue. + +- [x] T028 [P] Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [x] T029 [P] Run the focused writer-suite Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T030 [P] Run the focused shared-recurrence Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [x] T031 [P] Run the downstream-consumer and trigger-RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` +- [ ] T032 [P] Execute quickstart manual smoke steps 1 through 4 from `specs/255-enforce-finding-creation-invariants/quickstart.md` against `/admin/t/{tenant}/findings`, `MyFindingsInbox`, and `FindingsIntakeQueue`, then leave diff/scope review to `T034` +- [x] T033 [P] Run residue searches for `backfill`, `repair`, `constraint`, `migration`, and any new `Finding::STATUS_` additions across `apps/platform/app/`, `apps/platform/tests/`, `apps/platform/database/`, and `specs/255-enforce-finding-creation-invariants/`, then classify each remaining match as allowed shared-consumer proof, in-scope cleanup to delete now, or `reject-or-split` +- [x] T034 Verify that no file under `apps/platform/database/migrations/` changed, no new repair or rollout entry point appeared under `apps/platform/app/Console/Commands/` or `apps/platform/app/Services/Runbooks/`, and no direct workflow expansion landed in `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, or `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`; if truth-consumer proof from `T025` and `T031` suggests a direct UI edit is necessary, stop and record that as `document-in-feature` or `reject-or-split` instead of treating it as default in-scope work + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the exact scope, writer inventory, and proving commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, fixtures, and adjacent cleanup guardrails are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and establishes the lifecycle-ready create contract. +- **User Story 2 (Phase 4)**: Depends on User Story 1 because reopen semantics should refresh the same lifecycle-ready contract established at creation time. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because recurrence and consumer proof only mean the right thing after create and reopen behavior are aligned. +- **Polish (Phase 6)**: Depends on all desired user stories being complete so final validation and residue checks run on the finished slice. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: Depends on US1. +- **US3**: Depends on US1 and US2. + +### Within Each User Story + +- Add or update the story tests first and confirm they fail before implementation edits are considered complete. +- Keep recurrence identity family-owned instead of introducing a generic invariant framework. +- Keep downstream findings surfaces as proof consumers unless shared-truth tests prove a concrete need for direct edits. +- Keep migrations, DB constraints, repair tooling, acknowledged cleanup, external support-desk or PSA work, customer-facing workflow changes, and broad findings redesign out of scope. + +### Parallel Opportunities + +- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup. +- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work. +- `T009`, `T010`, and `T011` can run in parallel for User Story 1 before `T012`, `T013`, `T014`, and `T015`. +- `T016`, `T017`, `T018`, and `T019` can run in parallel for User Story 2 before `T020`, `T021`, and `T022`. +- `T023`, `T024`, and `T025` can run in parallel for User Story 3 before `T026` and `T027`. +- `T029`, `T030`, `T031`, `T032`, and `T033` can run in parallel during final validation after `T028`, followed by `T034` as the final scope-boundary check. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T009 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +T010 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +T011 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php + +# User Story 1 implementation after the tests are in place +T012 apps/platform/app/Jobs/CompareBaselineToTenantJob.php +T013 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php +T014 apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T016 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +T017 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +T018 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +T019 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php + +# User Story 2 implementation after the tests are in place +T020 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Jobs/CompareBaselineToTenantJob.php +T021 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel +T023 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php +T024 apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php + apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php +T025 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php + +# User Story 3 implementation after the tests are in place +T026 apps/platform/app/Jobs/CompareBaselineToTenantJob.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +T027 apps/platform/app/Services/Findings/ +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 and 2) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Run `T028`, `T029`, and `T030` before widening into recurrence and consumer-proof cleanup. + +### Incremental Delivery + +1. Lock the bounded writer inventory, proof files, and stop conditions. +2. Make create-time lifecycle readiness explicit for baseline compare, Entra admin roles, and permission posture. +3. Preserve the shared reopen path and refresh lifecycle truth when resolved findings return. +4. Tighten recurrence, idempotence, and downstream proof without widening into UI redesign or repair tooling. +5. Finish with focused Sail validation, manual smoke, and residue checks. + +### Parallel Team Strategy + +1. One contributor can own the three writer-family tests while another confirms shared recurrence and downstream consumer proof after Phase 2. +2. After User Story 1 lands, one contributor can align the reopen path while another prepares the recurrence and consumer proof for User Story 3. +3. Finish with one bounded pass for formatting, focused Sail validation, and residue or scope-boundary review. + +--- + +## Notes + +- Suggested MVP scope: Phase 1 through Phase 4. Creation readiness without reopen reuse is not sufficient for this feature. +- Explicit non-goals remain: runtime backfill surfaces, acknowledged cleanup, new workflow states, broad findings redesign, migrations or DB constraints, repair tooling, external support-desk or PSA work, and customer-facing workflow expansion. +- Filament remains on Livewire v4.0+; no panel/provider or asset strategy changes are needed, and `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location if later Filament work is ever required. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths. \ No newline at end of file -- 2.45.2 From 4b0dc2a62ec7765cf53ad05928cb914066963b18 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 29 Apr 2026 14:56:17 +0200 Subject: [PATCH 5/7] chore: commit workspace changes (automated) --- docs/product/implementation-ledger.md | 67 +++++--- specs/900-policy-lifecycle/spec.md | 228 -------------------------- 2 files changed, 40 insertions(+), 255 deletions(-) delete mode 100644 specs/900-policy-lifecycle/spec.md diff --git a/docs/product/implementation-ledger.md b/docs/product/implementation-ledger.md index b5f12e4d..8bec9042 100644 --- a/docs/product/implementation-ledger.md +++ b/docs/product/implementation-ledger.md @@ -15,7 +15,7 @@ ## Purpose ## Current Product Position -TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. +TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls und inzwischen repo-real umgesetzten Customer-safe Review Consumption, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance bleiben unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. ## Status Model @@ -41,24 +41,24 @@ ## Roadmap Coverage Summary | Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes | |---|---|---:|---|---|---|---| | R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. | -| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. | +| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | yes | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen. | | Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. | | Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. | -| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. | +| UI & Product Maturity Polish | implemented_partial | strong | partial | partial repo tests, not run | no | Empty States, Navigation, Localization und read-only Review-Polish sind real, aber kein geschlossenes Theme-Completion-Signal. | | Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. | | Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. | -| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. | +| R1.9 Platform Localization v1 | implemented_verified | strong | yes | repo tests, not run | foundation-only | Locale-Resolver, Override/Praeferenz, Workspace-Default, Fallback und lokalisierte Notifications sind repo-real. | | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | -| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. | -| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. | +| R2 Completion: customer review, support, help | adopted | strong | yes | repo tests, not run | yes | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real. | +| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | | Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. | | MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. | -| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. | -| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. | +| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow jenseits des jetzigen Exception-/Review-Layers. | +| Drift & Change Governance | implemented_partial | strong | yes | repo tests, not run | almost | Drift review, accepted-risk governance, exception validity und Governance-Inbox-Surfaces sind repo-real; portfolio-weite Eskalation bleibt offen. | | Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. | | PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. | @@ -69,10 +69,13 @@ ## Implemented Capabilities | OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` | | Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` | | Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` | +| Findings inboxes and governance inbox | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Findings/MyFindingsInbox.php`; `app/Filament/Pages/Findings/FindingsIntakeQueue.php`; `app/Filament/Pages/Governance/GovernanceInbox.php`; `tests/Feature/Findings/MyWorkInboxTest.php`; `tests/Feature/Governance/*` | +| Finding exceptions and risk acceptance workflow | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/FindingException.php`; `app/Services/Findings/FindingExceptionService.php`; `app/Filament/Resources/FindingExceptionResource.php`; `tests/Feature/Findings/FindingExceptionWorkflowTest.php` | | Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` | | Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` | | Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` | | Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` | +| Customer review workspace | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` | | Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` | | Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` | | Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` | @@ -81,6 +84,7 @@ ## Implemented Capabilities | Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` | | In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` | | Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` | +| Localization foundation | implemented_verified | yes | yes | repo tests, not run | partial | foundation-only | `app/Services/Localization/LocaleResolver.php`; `app/Http/Controllers/LocalizationController.php`; `tests/Feature/Localization/*` | | Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` | | Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` | | Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` | @@ -99,14 +103,15 @@ ## Foundation-Only Capabilities - Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews. - Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen. - Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports. +- Localization foundation: resolved locale precedence, Workspace-Default, User-Praeferenz/Override und Notification-Formatting sind real, aber Enablement statt eigener Produkt-Surface. - Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig. - Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche. - Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt. ## Partial Capabilities -- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt. -- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten. +- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber portfolio-weite Consumption- und Sharing-Patterns bleiben offen. +- Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. @@ -114,13 +119,12 @@ ## Partial Capabilities ## Planned But Not Implemented -- Platform Localization v1 - Private AI Execution & Usage Governance Foundation - Human-in-the-Loop Autonomous Governance - Standardization & Policy Quality / Intune Linting - PSA / Ticketing Handoff -- Customer Review Workspace v1 - Cross-Tenant Compare and Promotion v1 +- Policy Lifecycle / Ghost Policies - Later compliance overlays beyond the current control/evidence foundation ## Release Readiness @@ -128,8 +132,8 @@ ## Release Readiness | Release / Theme | Readiness | Notes | |---|---|---| | R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. | -| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. | -| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. | +| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; breitere Commercial-Polish-Themen bleiben separat. | +| R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. | | Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. | ## Commercial Readiness @@ -138,14 +142,16 @@ ### Demo-ready - Baseline compare and drift walkthroughs - Review pack generation and export +- Customer-safe review workspace walkthroughs - Provider health, onboarding readiness and required permissions - Support diagnostics - Permission posture and Entra admin roles reporting ### Almost sellable -- Review-driven governance workflow around tenant reviews and review packs +- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs - Baseline drift and restore governance +- Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling - Alerting and run visibility for governance operations - Support requests with contextual diagnostics - Provider readiness and permission posture reporting @@ -159,6 +165,7 @@ ### Foundation-only - Canonical control catalog - Stored reports substrate - Evidence snapshot substrate +- Localization foundation - Product telemetry - Customer health scoring - Operational controls @@ -166,9 +173,7 @@ ### Foundation-only ### Not sellable yet -- Customer Review Workspace v1 - Cross-Tenant Compare and Promotion v1 -- Localization v1 - Private AI Execution Governance Foundation - External Support Desk / PSA Handoff - Compliance Light product layer @@ -177,40 +182,39 @@ ## Open Gaps & Blockers | Gap | Type | Impact | Roadmap Area | Recommended Spec | |---|---|---|---|---| -| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 | -| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 | +| Decisioning still spans multiple repo-real inboxes | UX blocker | My Findings, Intake, Governance Inbox und Exception Queue sind real, aber Operators springen weiter zwischen mehreren Spezial-Surfaces und es gibt noch keinen portfolio-weiten Action-Layer | Findings Workflow / MSP Portfolio | P1 Governance Decision Surface Convergence | | Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces | | Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility | | Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | -| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | | Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff | | AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation | -| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment | +| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs still lag neuere Review-, Findings- und Localization-Surfaces | Product planning / roadmap maintenance | none - docs alignment | | Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites | ## Recommended Next Specs -- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface. -- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface. +- `P1 Governance Decision Surface Convergence`: verbindet My Findings, Intake, Governance Inbox, Customer Review Workspace und Exception Queue zu weniger Operator-Journeys und bereitet die Portfolio-Ebene vor. - `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks. - `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases. - `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. -- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. - `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence. - `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it. ## Roadmap Drift Notes +- `roadmap.md` understates current R2 completion. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind bereits repo-real. +- `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo. +- `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert. - `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas. - `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo. - `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel. - `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not. -- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented. -- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace. +- The roadmap is now better at describing still-missing portfolio- und commercial-Layer than the current state of review/findings/localization implementation. Cross-Tenant Compare and Promotion, full billing-state maturity, external PSA handoff and AI Governance still look genuinely unimplemented. +- The main drift pattern is underestimation, not overestimation. Customer-facing review consumption is no longer the clearest missing piece; portfolio action and commercial lifecycle are. ## Evidence Sources @@ -227,12 +231,19 @@ ## Evidence Sources - `apps/platform/app/Filament/Pages/TenantDashboard.php` - `apps/platform/app/Filament/System/Pages/Dashboard.php` - `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` +- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` +- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` +- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` Wichtige Models: - `apps/platform/app/Models/OperationRun.php` - `apps/platform/app/Models/Finding.php` - `apps/platform/app/Models/FindingException.php` +- `apps/platform/app/Models/FindingExceptionDecision.php` +- `apps/platform/app/Models/FindingExceptionEvidenceReference.php` - `apps/platform/app/Models/BaselineProfile.php` - `apps/platform/app/Models/BaselineSnapshot.php` - `apps/platform/app/Models/EvidenceSnapshot.php` @@ -251,6 +262,7 @@ ## Evidence Sources - `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` - `apps/platform/app/Services/Baselines/BaselineCompareService.php` - `apps/platform/app/Services/Alerts/AlertDispatchService.php` +- `apps/platform/app/Services/Findings/FindingExceptionService.php` - `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php` - `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php` - `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php` @@ -258,6 +270,7 @@ ## Evidence Sources - `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` - `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` - `apps/platform/app/Services/Auth/CapabilityResolver.php` +- `apps/platform/app/Services/Localization/LocaleResolver.php` Wichtige Test-Anker im Repo: @@ -276,4 +289,4 @@ ## Evidence Sources ## Last Updated -2026-04-27 on branch `248-private-ai-policy-foundation` +2026-04-29 on branch `platform-dev` diff --git a/specs/900-policy-lifecycle/spec.md b/specs/900-policy-lifecycle/spec.md deleted file mode 100644 index 873c1dc3..00000000 --- a/specs/900-policy-lifecycle/spec.md +++ /dev/null @@ -1,228 +0,0 @@ -# Feature 005: Policy Lifecycle Management - -## Overview -Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling. - -## Problem Statement -Currently, when a policy is deleted in Intune: -- ❌ Policy remains in TenantAtlas database indefinitely -- ❌ No indication that policy no longer exists in Intune -- ❌ Backup Items reference "ghost" policies -- ❌ Users cannot distinguish between active and deleted policies - -**Discovered during**: Feature 004 manual testing (user deleted policy in Intune) - -## Goals -- **Primary**: Implement soft delete for policies removed from Intune -- **Secondary**: Show clear UI indicators for deleted policies -- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions - -## Scope -- **Policy Sync**: Detect missing policies during `SyncPoliciesJob` -- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern) -- **UI**: Badge indicators, filters, restore capability -- **Audit**: Log when policies are soft-deleted and restored - ---- - -## User Stories - -### User Story 1 - Automatic Soft Delete on Sync - -**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state. - -**Acceptance Criteria:** -1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123", - **When** the next policy sync runs and "abc-123" is NOT returned by Graph API, - **Then** the policy is soft-deleted (sets `deleted_at = now()`) - -2. **Given** a soft-deleted policy, - **When** it re-appears in Intune (same `external_id`), - **Then** the policy is automatically restored (`deleted_at = null`) - -3. **Given** multiple policies are deleted in Intune, - **When** sync runs, - **Then** all missing policies are soft-deleted in a single transaction - ---- - -### User Story 2 - UI Indicators for Deleted Policies - -**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status. - -**Acceptance Criteria:** -1. **Given** I view a Backup Item referencing a deleted policy, - **When** I see the policy name, - **Then** it shows a red "Deleted" badge next to the name - -2. **Given** I view the Policies list, - **When** I enable the "Show Deleted" filter, - **Then** deleted policies appear with: - - Red "Deleted" badge - - Deleted date in "Last Synced" column - - Grayed-out appearance - -3. **Given** a policy was deleted, - **When** I view the Policy detail page, - **Then** I see: - - Warning banner: "This policy was deleted from Intune on {date}" - - All data remains readable (versions, snapshots, metadata) - ---- - -### User Story 3 - Restore Workflow - -**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations. - -**Acceptance Criteria:** -1. **Given** I view a deleted policy's detail page, - **When** I click the "Restore to Intune" action, - **Then** the restore wizard opens pre-filled with the latest policy snapshot - -2. **Given** a policy is successfully restored to Intune, - **When** the next sync runs, - **Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`) - ---- - -## Functional Requirements - -### Data Model - -**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern: -```php -Schema::table('policies', function (Blueprint $table) { - $table->softDeletes(); // deleted_at - $table->string('deleted_by')->nullable(); // admin email who triggered deletion -}); -``` - -**FR-005.2**: Policy model MUST use `SoftDeletes` trait: -```php -use Illuminate\Database\Eloquent\SoftDeletes; - -class Policy extends Model { - use SoftDeletes; -} -``` - -### Policy Sync Behavior - -**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies: -- Collect all `external_id` values returned by Graph API -- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)` -- Soft delete missing policies: `each(fn($p) => $p->delete())` - -**FR-005.4**: System MUST restore policies that re-appear: -- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()` -- If soft-deleted: call `$policy->restore()` -- Update `last_synced_at` timestamp - -**FR-005.5**: System MUST log audit entries: -- `policy.deleted` (when soft-deleted during sync) -- `policy.restored` (when re-appears in Intune) - -### UI Display - -**FR-005.6**: PolicyResource table MUST: -- Default query: exclude soft-deleted policies -- Add filter "Show Deleted" (includes `withTrashed()` in query) -- Show "Deleted" badge for soft-deleted policies - -**FR-005.7**: BackupItemsRelationManager MUST: -- Show "Deleted" badge when `policy->trashed()` returns true -- Allow viewing deleted policy details (read-only) - -**FR-005.8**: Policy detail view MUST: -- Show warning banner when policy is soft-deleted -- Display deletion date and reason (if available) -- Disable edit actions (policy no longer exists in Intune) - ---- - -## Non-Functional Requirements - -**NFR-005.1**: Soft delete MUST NOT break existing features: -- Backup Items keep valid foreign keys -- Policy Versions remain accessible -- Restore functionality works for deleted policies - -**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries: -- Use single `whereNotIn()` query to find missing policies -- Batch soft-delete operation - -**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging) - ---- - -## Implementation Plan - -### Phase 1: Data Model (30 min) -1. Create migration for `policies` soft delete columns -2. Add `SoftDeletes` trait to Policy model -3. Run migration on dev environment - -### Phase 2: Sync Logic (1 hour) -1. Update `PolicySyncService::syncPolicies()` - - Track current external IDs from Graph - - Soft delete missing policies - - Restore re-appeared policies -2. Add audit logging -3. Test with manual deletion in Intune - -### Phase 3: UI Indicators (1.5 hours) -1. Update `PolicyResource`: - - Add "Show Deleted" filter - - Add "Deleted" badge column - - Modify query to exclude deleted by default -2. Update `BackupItemsRelationManager`: - - Show "Deleted" badge for `policy->trashed()` -3. Update Policy detail view: - - Warning banner for deleted policies - - Disable edit actions - -### Phase 4: Testing (1 hour) -1. Unit tests: - - Test soft delete on sync - - Test restore on re-appearance -2. Feature tests: - - E2E sync with deleted policies - - UI filter behavior -3. Manual QA: - - Delete policy in Intune → sync → verify soft delete - - Re-create policy → sync → verify restore - -**Total Estimated Duration**: ~4-5 hours - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid | -| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies | -| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in | - ---- - -## Success Criteria -1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle -2. ✅ Re-appearing policies are automatically restored -3. ✅ UI clearly indicates deleted status -4. ✅ Backup Items and Versions remain accessible for deleted policies -5. ✅ No breaking changes to existing features - ---- - -## Related Features -- Feature 004: Assignments & Scope Tags (discovered this issue during testing) -- Feature 001: Backup/Restore (must work with deleted policies) - ---- - -**Status**: Planned (Post-Feature 004) -**Priority**: P2 (Quality of Life improvement) -**Created**: 2025-12-22 -**Author**: AI + Ahmed -**Next Steps**: Implement after Feature 004 Phase 3 testing complete -- 2.45.2 From 52ebf63af1e87570970d9fea66b75fff10fbf6d3 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 20:16:40 +0000 Subject: [PATCH 6/7] feat(specs/256): external support desk handoff (#301) Implement external support desk handoff (spec 256). Created and pushed branch `256-external-support-desk-handoff`. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/301 --- apps/platform/.env.example | 7 + .../TenantlessOperationRunViewer.php | 165 ++++++++- .../app/Filament/Pages/TenantDashboard.php | 142 +++++++- apps/platform/app/Models/SupportRequest.php | 61 ++++ .../Services/Audit/WorkspaceAuditLogger.php | 83 +++++ .../app/Support/Audit/AuditActionId.php | 9 + .../ExternalSupportDeskHandoffService.php | 256 ++++++++++++++ .../SupportRequestSubmissionService.php | 215 +++++++++++- apps/platform/config/support_desk.php | 14 + .../factories/SupportRequestFactory.php | 4 + ...ndoff_fields_to_support_requests_table.php | 35 ++ apps/platform/lang/de/localization.php | 28 +- apps/platform/lang/en/localization.php | 28 +- ...onRunSupportRequestExternalHandoffTest.php | 148 ++++++++ ...SupportRequestExternalHandoffAuditTest.php | 140 ++++++++ ...equestExternalHandoffAuthorizationTest.php | 131 +++++++ ...enantSupportRequestExternalHandoffTest.php | 187 ++++++++++ .../ExternalSupportDeskHandoffServiceTest.php | 121 +++++++ ...SupportRequestLatestHandoffSummaryTest.php | 113 ++++++ .../checklists/requirements.md | 63 ++++ ...-support-desk-handoff.logical.openapi.yaml | 216 ++++++++++++ .../data-model.md | 161 +++++++++ .../256-external-support-desk-handoff/plan.md | 319 +++++++++++++++++ .../quickstart.md | 48 +++ .../research.md | 167 +++++++++ .../256-external-support-desk-handoff/spec.md | 331 ++++++++++++++++++ .../tasks.md | 192 ++++++++++ 27 files changed, 3365 insertions(+), 19 deletions(-) create mode 100644 apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php create mode 100644 apps/platform/config/support_desk.php create mode 100644 apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php create mode 100644 apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php create mode 100644 apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php create mode 100644 apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php create mode 100644 apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php create mode 100644 apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php create mode 100644 specs/256-external-support-desk-handoff/checklists/requirements.md create mode 100644 specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml create mode 100644 specs/256-external-support-desk-handoff/data-model.md create mode 100644 specs/256-external-support-desk-handoff/plan.md create mode 100644 specs/256-external-support-desk-handoff/quickstart.md create mode 100644 specs/256-external-support-desk-handoff/research.md create mode 100644 specs/256-external-support-desk-handoff/spec.md create mode 100644 specs/256-external-support-desk-handoff/tasks.md diff --git a/apps/platform/.env.example b/apps/platform/.env.example index 203738b3..4759a485 100644 --- a/apps/platform/.env.example +++ b/apps/platform/.env.example @@ -59,6 +59,13 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +SUPPORT_DESK_ENABLED=false +SUPPORT_DESK_NAME="External support desk" +SUPPORT_DESK_CREATE_URL= +SUPPORT_DESK_API_TOKEN= +SUPPORT_DESK_TICKET_URL_TEMPLATE= +SUPPORT_DESK_TIMEOUT_SECONDS=5 + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index f7ae56f4..ee56279b 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -31,6 +31,7 @@ use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\Rbac\UiEnforcement; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; +use App\Support\SupportRequests\ExternalSupportDeskHandoffService; use App\Support\SupportRequests\SupportRequestSubmissionService; use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\TenantInteractionLane; @@ -49,6 +50,7 @@ use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Illuminate\Contracts\View\View; use Illuminate\Contracts\Support\Htmlable; @@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void private function requestSupportAction(): Action { $action = Action::make('requestSupport') - ->label('Request support') + ->label(__('localization.dashboard.request_support')) ->icon('heroicon-o-paper-airplane') ->record($this->run) ->slideOver() ->stickyModalHeader() - ->modalHeading('Request support') - ->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.') - ->modalSubmitActionLabel('Submit support request') + ->modalHeading(__('localization.dashboard.support_request_heading')) + ->modalDescription(__('localization.dashboard.support_request_run_description')) + ->modalSubmitActionLabel(__('localization.dashboard.submit_request')) ->form([ Placeholder::make('primary_context') - ->label('Primary context') + ->label(__('localization.dashboard.primary_context')) ->content(fn (): string => OperationRunLinks::identifier($this->run)) ->columnSpanFull(), Placeholder::make('included_context') - ->label('Included context') + ->label(__('localization.dashboard.included_context')) ->content(fn (): string => $this->operationSupportRequestAttachmentSummary()) ->columnSpanFull(), + Placeholder::make('latest_external_handoff') + ->label(__('localization.dashboard.latest_external_handoff')) + ->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary()) + ->columnSpanFull(), + Select::make('external_handoff_mode') + ->label(__('localization.dashboard.external_handoff_mode')) + ->options(fn (): array => $this->supportHandoffModeOptions()) + ->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->helperText(fn (): string => $this->supportDeskTargetAvailable() + ? __('localization.dashboard.external_handoff_mode_helper_available') + : __('localization.dashboard.external_handoff_mode_helper_unavailable')) + ->required() + ->live() + ->native(false), + Placeholder::make('handoff_mutation_scope') + ->label(__('localization.dashboard.handoff_mutation_scope')) + ->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode'))) + ->columnSpanFull(), + TextInput::make('external_ticket_reference') + ->label(__('localization.dashboard.external_ticket_reference')) + ->helperText(__('localization.dashboard.external_ticket_reference_helper')) + ->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET), + TextInput::make('external_ticket_url') + ->label(__('localization.dashboard.external_ticket_url')) + ->helperText(__('localization.dashboard.external_ticket_url_helper')) + ->url() + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->columnSpanFull(), Select::make('severity') - ->label('Severity') + ->label(__('localization.dashboard.severity')) ->options(SupportRequest::severityOptions()) ->default(SupportRequest::SEVERITY_NORMAL) ->required() ->native(false), TextInput::make('summary') - ->label('Summary') + ->label(__('localization.dashboard.summary')) ->required() ->columnSpanFull(), Textarea::make('reproduction_notes') - ->label('Reproduction notes') + ->label(__('localization.dashboard.reproduction_notes')) ->rows(4) ->columnSpanFull(), TextInput::make('contact_name') - ->label('Contact name') + ->label(__('localization.dashboard.contact_name')) ->default(fn (): ?string => $this->resolveViewerActor()->name), TextInput::make('contact_email') - ->label('Contact email') + ->label(__('localization.dashboard.contact_email')) ->email() ->default(fn (): ?string => $this->resolveViewerActor()->email), ]) @@ -312,9 +345,21 @@ private function requestSupportAction(): Action $supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data); Notification::make() - ->title('Support request submitted') - ->body('Reference '.$supportRequest->internal_reference) - ->success() + ->title(__('localization.dashboard.support_request_submitted')) + ->body($this->supportRequestNotificationBody($supportRequest)) + ->when( + $supportRequest->hasExternalHandoffFailure(), + fn (Notification $notification): Notification => $notification->warning(), + fn (Notification $notification): Notification => $notification->success(), + ) + ->when( + $supportRequest->external_ticket_url !== null, + fn (Notification $notification): Notification => $notification->actions([ + Action::make('openExternalTicket') + ->label(__('localization.dashboard.open_external_ticket')) + ->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true), + ]), + ) ->send(); }); @@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string : 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.'; } + private function operationLatestSupportRequestHandoffSummary(): string + { + $user = $this->resolveViewerActor(); + + $summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user); + + return $this->formatLatestHandoffSummary($summary); + } + + /** + * @return array + */ + private function supportHandoffModeOptions(): array + { + if (! $this->supportDeskTargetAvailable()) { + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + ]; + } + + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'), + ]; + } + + private function supportDeskTargetAvailable(): bool + { + return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured(); + } + + private function externalHandoffMutationScope(mixed $mode): string + { + return match ($mode) { + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'), + default => __('localization.dashboard.mutation_scope_internal_only'), + }; + } + + /** + * @param array|null $summary + */ + private function formatLatestHandoffSummary(?array $summary): string + { + if ($summary === null) { + return __('localization.dashboard.latest_external_handoff_none'); + } + + $internalReference = (string) $summary['internal_reference']; + + if (($summary['has_failure'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_failed', [ + 'reference' => $internalReference, + 'failure' => (string) $summary['external_handoff_failure_summary'], + ]); + } + + if (($summary['has_external_link'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_linked', [ + 'reference' => $internalReference, + 'external' => (string) $summary['external_ticket_reference'], + ]); + } + + return __('localization.dashboard.latest_external_handoff_internal_only', [ + 'reference' => $internalReference, + ]); + } + + private function supportRequestNotificationBody(SupportRequest $supportRequest): string + { + return match ($supportRequest->externalHandoffOutcome()) { + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [ + 'reference' => $supportRequest->internal_reference, + 'failure' => $supportRequest->external_handoff_failure_summary, + ]), + default => __('localization.dashboard.support_request_submitted_internal_only', [ + 'reference' => $supportRequest->internal_reference, + ]), + }; + } + /** * @param array $bundle */ diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 8af8dbe7..63b1fb61 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -21,6 +21,7 @@ use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\Rbac\UiEnforcement; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; +use App\Support\SupportRequests\ExternalSupportDeskHandoffService; use App\Support\SupportRequests\SupportRequestSubmissionService; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -30,6 +31,7 @@ use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; use Filament\Pages\Dashboard; +use Filament\Schemas\Components\Utilities\Get; use Filament\Widgets\Widget; use Filament\Widgets\WidgetConfiguration; use Illuminate\Contracts\View\View; @@ -108,6 +110,37 @@ private function requestSupportAction(): Action ->label(__('localization.dashboard.included_context')) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->columnSpanFull(), + Placeholder::make('latest_external_handoff') + ->label(__('localization.dashboard.latest_external_handoff')) + ->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary()) + ->columnSpanFull(), + Select::make('external_handoff_mode') + ->label(__('localization.dashboard.external_handoff_mode')) + ->options(fn (): array => $this->supportHandoffModeOptions()) + ->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->helperText(fn (): string => $this->supportDeskTargetAvailable() + ? __('localization.dashboard.external_handoff_mode_helper_available') + : __('localization.dashboard.external_handoff_mode_helper_unavailable')) + ->required() + ->live() + ->native(false), + Placeholder::make('handoff_mutation_scope') + ->label(__('localization.dashboard.handoff_mutation_scope')) + ->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode'))) + ->columnSpanFull(), + TextInput::make('external_ticket_reference') + ->label(__('localization.dashboard.external_ticket_reference')) + ->helperText(__('localization.dashboard.external_ticket_reference_helper')) + ->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET), + TextInput::make('external_ticket_url') + ->label(__('localization.dashboard.external_ticket_url')) + ->helperText(__('localization.dashboard.external_ticket_url_helper')) + ->url() + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->columnSpanFull(), Select::make('severity') ->label(__('localization.dashboard.severity')) ->options(SupportRequest::severityOptions()) @@ -138,8 +171,20 @@ private function requestSupportAction(): Action Notification::make() ->title(__('localization.dashboard.support_request_submitted')) - ->body('Reference '.$supportRequest->internal_reference) - ->success() + ->body($this->supportRequestNotificationBody($supportRequest)) + ->when( + $supportRequest->hasExternalHandoffFailure(), + fn (Notification $notification): Notification => $notification->warning(), + fn (Notification $notification): Notification => $notification->success(), + ) + ->when( + $supportRequest->external_ticket_url !== null, + fn (Notification $notification): Notification => $notification->actions([ + Action::make('openExternalTicket') + ->label(__('localization.dashboard.open_external_ticket')) + ->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true), + ]), + ) ->send(); }); @@ -281,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string ? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.' : 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.'; } + + private function tenantLatestSupportRequestHandoffSummary(): string + { + $tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE); + $user = $this->resolveDashboardActor(); + + $summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user); + + return $this->formatLatestHandoffSummary($summary); + } + + /** + * @return array + */ + private function supportHandoffModeOptions(): array + { + if (! $this->supportDeskTargetAvailable()) { + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + ]; + } + + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'), + ]; + } + + private function supportDeskTargetAvailable(): bool + { + return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured(); + } + + private function externalHandoffMutationScope(mixed $mode): string + { + return match ($mode) { + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'), + default => __('localization.dashboard.mutation_scope_internal_only'), + }; + } + + /** + * @param array|null $summary + */ + private function formatLatestHandoffSummary(?array $summary): string + { + if ($summary === null) { + return __('localization.dashboard.latest_external_handoff_none'); + } + + $internalReference = (string) $summary['internal_reference']; + + if (($summary['has_failure'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_failed', [ + 'reference' => $internalReference, + 'failure' => (string) $summary['external_handoff_failure_summary'], + ]); + } + + if (($summary['has_external_link'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_linked', [ + 'reference' => $internalReference, + 'external' => (string) $summary['external_ticket_reference'], + ]); + } + + return __('localization.dashboard.latest_external_handoff_internal_only', [ + 'reference' => $internalReference, + ]); + } + + private function supportRequestNotificationBody(SupportRequest $supportRequest): string + { + return match ($supportRequest->externalHandoffOutcome()) { + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [ + 'reference' => $supportRequest->internal_reference, + 'failure' => $supportRequest->external_handoff_failure_summary, + ]), + default => __('localization.dashboard.support_request_submitted_internal_only', [ + 'reference' => $supportRequest->internal_reference, + ]), + }; + } } diff --git a/apps/platform/app/Models/SupportRequest.php b/apps/platform/app/Models/SupportRequest.php index 1b252355..eaf5fb5b 100644 --- a/apps/platform/app/Models/SupportRequest.php +++ b/apps/platform/app/Models/SupportRequest.php @@ -32,6 +32,20 @@ class SupportRequest extends Model public const string SEVERITY_BLOCKING = 'blocking'; + public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only'; + + public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket'; + + public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket'; + + public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only'; + + public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created'; + + public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked'; + + public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed'; + protected $guarded = []; /** @@ -65,6 +79,53 @@ public static function severityValues(): array return array_keys(self::severityOptions()); } + /** + * @return array + */ + public static function externalHandoffModeOptions(): array + { + return [ + self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only', + self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket', + self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket', + ]; + } + + /** + * @return list + */ + public static function externalHandoffModeValues(): array + { + return array_keys(self::externalHandoffModeOptions()); + } + + public function hasExternalTicket(): bool + { + return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== ''; + } + + public function hasExternalHandoffFailure(): bool + { + return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== ''; + } + + public function externalHandoffOutcome(): string + { + if ($this->hasExternalHandoffFailure()) { + return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED; + } + + if (! $this->hasExternalTicket()) { + return self::HANDOFF_OUTCOME_INTERNAL_ONLY; + } + + return match ($this->external_handoff_mode) { + self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED, + self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED, + default => self::HANDOFF_OUTCOME_INTERNAL_ONLY, + }; + } + /** * @return list */ diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index 0cc9bcc3..51b14aef 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -173,4 +173,87 @@ public function logSupportRequestCreated( tenant: $tenant, ); } + + public function logSupportRequestExternalTicketCreated( + SupportRequest $supportRequest, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + return $this->logSupportRequestExternalHandoff( + supportRequest: $supportRequest, + actor: $actor, + action: AuditActionId::SupportRequestExternalTicketCreated, + status: 'success', + summaryPrefix: 'External ticket created for support request ', + ); + } + + public function logSupportRequestExternalTicketLinked( + SupportRequest $supportRequest, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + return $this->logSupportRequestExternalHandoff( + supportRequest: $supportRequest, + actor: $actor, + action: AuditActionId::SupportRequestExternalTicketLinked, + status: 'success', + summaryPrefix: 'External ticket linked for support request ', + ); + } + + public function logSupportRequestExternalHandoffFailed( + SupportRequest $supportRequest, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + return $this->logSupportRequestExternalHandoff( + supportRequest: $supportRequest, + actor: $actor, + action: AuditActionId::SupportRequestExternalHandoffFailed, + status: 'failed', + summaryPrefix: 'External handoff failed for support request ', + ); + } + + private function logSupportRequestExternalHandoff( + SupportRequest $supportRequest, + User|PlatformUser|null $actor, + AuditActionId $action, + string $status, + string $summaryPrefix, + ): \App\Models\AuditLog { + $supportRequest->loadMissing(['tenant.workspace']); + + $tenant = $supportRequest->tenant; + + if (! $tenant instanceof Tenant) { + throw new InvalidArgumentException('Support requests must belong to a tenant.'); + } + + $metadata = [ + 'internal_reference' => $supportRequest->internal_reference, + 'primary_context_type' => $supportRequest->primary_context_type, + 'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN + ? (string) $supportRequest->operation_run_id + : (string) $tenant->getKey(), + 'external_handoff_mode' => $supportRequest->external_handoff_mode, + 'external_ticket_reference' => $supportRequest->external_ticket_reference, + ]; + + if ($supportRequest->external_handoff_failure_summary !== null) { + $metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary; + } + + return $this->log( + workspace: $tenant->workspace, + action: $action, + context: $metadata, + actor: $actor, + status: $status, + resourceType: 'support_request', + resourceId: (string) $supportRequest->getKey(), + targetLabel: $supportRequest->internal_reference, + summary: $summaryPrefix.$supportRequest->internal_reference, + operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null, + tenant: $tenant, + ); + } } diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 1e17bc1b..5dd9bd45 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -103,6 +103,9 @@ enum AuditActionId: string case SupportDiagnosticsOpened = 'support_diagnostics.opened'; case SupportRequestCreated = 'support_request.created'; + case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created'; + case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked'; + case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed'; case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated'; case OperationalControlPaused = 'operational_control.paused'; case OperationalControlUpdated = 'operational_control.updated'; @@ -248,6 +251,9 @@ private static function labels(): array self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportRequestCreated->value => 'Support request created', + self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created', + self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked', + self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlUpdated->value => 'Operational control updated', @@ -338,6 +344,9 @@ private static function summaries(): array self::ReviewPackDownloaded->value => 'Review pack downloaded', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportRequestCreated->value => 'Support request created', + self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created', + self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked', + self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlUpdated->value => 'Operational control updated', diff --git a/apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php b/apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php new file mode 100644 index 00000000..7e37e657 --- /dev/null +++ b/apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php @@ -0,0 +1,256 @@ +targetIsConfigured()) { + return $this->failed('External support desk target is not configured.'); + } + + try { + $response = Http::timeout($this->timeoutSeconds()) + ->acceptJson() + ->asJson() + ->withHeaders($this->headers()) + ->post($this->createUrl(), $this->payloadFor($supportRequest)); + } catch (ConnectionException) { + return $this->failed('External support desk did not respond before the configured timeout.'); + } catch (RequestException $exception) { + return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').'); + } + + if (! $response->successful()) { + return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').'); + } + + $responsePayload = $response->json(); + $responsePayload = is_array($responsePayload) ? $responsePayload : []; + + $reference = $this->normalizeReference( + data_get($responsePayload, 'ticket_reference') + ?? data_get($responsePayload, 'external_ticket_reference') + ?? data_get($responsePayload, 'reference') + ?? data_get($responsePayload, 'key') + ?? data_get($responsePayload, 'id'), + throwOnInvalid: false, + ); + + if ($reference === null) { + return $this->failed('External support desk did not return a ticket reference.'); + } + + $url = $this->normalizeUrl( + data_get($responsePayload, 'ticket_url') + ?? data_get($responsePayload, 'external_ticket_url') + ?? data_get($responsePayload, 'url') + ?? data_get($responsePayload, 'web_url') + ?? data_get($responsePayload, 'html_url'), + throwOnInvalid: false, + ) ?? $this->urlFromTemplate($reference); + + return [ + 'successful' => true, + 'external_ticket_reference' => $reference, + 'external_ticket_url' => $url, + 'failure_summary' => null, + ]; + } + + /** + * @return array{external_ticket_reference: string, external_ticket_url: ?string} + */ + public function normalizeLinkedTicket(mixed $reference, mixed $url): array + { + $normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true); + + if ($normalizedReference === null) { + throw ValidationException::withMessages([ + 'external_ticket_reference' => 'The external ticket reference field is required.', + ]); + } + + return [ + 'external_ticket_reference' => $normalizedReference, + 'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference), + ]; + } + + public function targetIsConfigured(): bool + { + return (bool) config('support_desk.target.enabled', false) + && $this->createUrl() !== null; + } + + public function targetName(): string + { + $name = config('support_desk.target.name', 'External support desk'); + + return is_string($name) && trim($name) !== '' + ? trim($name) + : 'External support desk'; + } + + public function timeoutSeconds(): int + { + $configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS); + + $seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS; + + return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS)); + } + + /** + * @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string} + */ + private function failed(string $summary): array + { + return [ + 'successful' => false, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'failure_summary' => $this->boundedFailureSummary($summary), + ]; + } + + private function createUrl(): ?string + { + return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false); + } + + /** + * @return array + */ + private function headers(): array + { + $headers = []; + $token = config('support_desk.target.api_token'); + + if (is_string($token) && trim($token) !== '') { + $headers['Authorization'] = 'Bearer '.trim($token); + } + + return $headers; + } + + /** + * @return array + */ + private function payloadFor(SupportRequest $supportRequest): array + { + return [ + 'support_request' => [ + 'internal_reference' => $supportRequest->internal_reference, + 'severity' => $supportRequest->severity, + 'summary' => $supportRequest->summary, + 'reproduction_notes' => $supportRequest->reproduction_notes, + 'contact_name' => $supportRequest->contact_name, + 'contact_email' => $supportRequest->contact_email, + 'primary_context_type' => $supportRequest->primary_context_type, + 'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN + ? $supportRequest->operation_run_id + : $supportRequest->tenant_id, + 'workspace_id' => $supportRequest->workspace_id, + 'tenant_id' => $supportRequest->tenant_id, + 'operation_run_id' => $supportRequest->operation_run_id, + ], + 'context_envelope' => $supportRequest->context_envelope, + ]; + } + + private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string + { + if (! is_string($value) && ! is_numeric($value)) { + return null; + } + + $reference = trim((string) $value); + + if ($reference === '') { + return null; + } + + if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) { + if ($throwOnInvalid) { + throw ValidationException::withMessages([ + 'external_ticket_reference' => 'The external ticket reference format is invalid.', + ]); + } + + return null; + } + + return $reference; + } + + private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string + { + if (! is_string($value)) { + return null; + } + + $url = trim($value); + + if ($url === '') { + return null; + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) { + if ($throwOnInvalid) { + throw ValidationException::withMessages([ + 'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.', + ]); + } + + return null; + } + + return $url; + } + + private function urlFromTemplate(string $reference): ?string + { + $template = config('support_desk.target.ticket_url_template'); + + if (! is_string($template) || trim($template) === '') { + return null; + } + + $url = str_replace( + ['{reference}', '{ticket}'], + rawurlencode($reference), + trim($template), + ); + + return $this->normalizeUrl($url, throwOnInvalid: false); + } + + private function boundedFailureSummary(string $summary): string + { + $summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary); + + return mb_substr($summary, 0, 500); + } +} diff --git a/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php b/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php index 48e51f00..2083c91c 100644 --- a/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php +++ b/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php @@ -20,6 +20,7 @@ public function __construct( private readonly CapabilityResolver $capabilityResolver, private readonly SupportRequestContextBuilder $supportRequestContextBuilder, private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator, + private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService, private readonly WorkspaceAuditLogger $workspaceAuditLogger, ) {} @@ -95,7 +96,7 @@ private function submit( $contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email); $connection = SupportRequest::query()->getModel()->getConnection(); - return $connection->transaction(function () use ( + $supportRequest = $connection->transaction(function () use ( $actor, $contactEmail, $contactName, @@ -127,6 +128,181 @@ private function submit( return $supportRequest; }); + + return $this->finalizeExternalHandoff($supportRequest, $actor, $validated); + } + + /** + * @param array{ + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string + * } $validated + */ + private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest + { + $mode = $validated['external_handoff_mode']; + + if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) { + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'external_handoff_failure_summary' => null, + ])->save(); + + return $supportRequest->refresh(); + } + + if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) { + $linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket( + $validated['external_ticket_reference'], + $validated['external_ticket_url'], + ); + + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => $linkedTicket['external_ticket_reference'], + 'external_ticket_url' => $linkedTicket['external_ticket_url'], + 'external_handoff_failure_summary' => null, + ])->save(); + + $supportRequest = $supportRequest->refresh(); + $this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor); + + return $supportRequest; + } + + $createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest); + + if ($createdTicket['successful']) { + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + 'external_ticket_reference' => $createdTicket['external_ticket_reference'], + 'external_ticket_url' => $createdTicket['external_ticket_url'], + 'external_handoff_failure_summary' => null, + ])->save(); + + $supportRequest = $supportRequest->refresh(); + $this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor); + + return $supportRequest; + } + + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'external_handoff_failure_summary' => $createdTicket['failure_summary'], + ])->save(); + + $supportRequest = $supportRequest->refresh(); + $this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor); + + return $supportRequest; + } + + /** + * @return array{ + * internal_reference: string, + * primary_context_type: string, + * primary_context_id: int|null, + * submitted_at: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, + * external_handoff_failure_summary: ?string, + * has_external_link: bool, + * has_failure: bool + * }|null + */ + public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array + { + $this->authorizeCreation($tenant, $actor); + + $supportRequest = SupportRequest::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT) + ->latest('created_at') + ->latest('id') + ->first(); + + return $supportRequest instanceof SupportRequest + ? $this->summaryFor($supportRequest) + : null; + } + + /** + * @return array{ + * internal_reference: string, + * primary_context_type: string, + * primary_context_id: int|null, + * submitted_at: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, + * external_handoff_failure_summary: ?string, + * has_external_link: bool, + * has_failure: bool + * }|null + */ + public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array + { + $run->loadMissing('tenant.workspace'); + + $tenant = $run->tenant; + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $this->authorizeCreation($tenant, $actor); + + $supportRequest = SupportRequest::query() + ->where('workspace_id', (int) $run->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN) + ->where('operation_run_id', (int) $run->getKey()) + ->latest('created_at') + ->latest('id') + ->first(); + + return $supportRequest instanceof SupportRequest + ? $this->summaryFor($supportRequest) + : null; + } + + /** + * @return array{ + * internal_reference: string, + * primary_context_type: string, + * primary_context_id: int|null, + * submitted_at: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, + * external_handoff_failure_summary: ?string, + * has_external_link: bool, + * has_failure: bool + * } + */ + private function summaryFor(SupportRequest $supportRequest): array + { + return [ + 'internal_reference' => (string) $supportRequest->internal_reference, + 'primary_context_type' => (string) $supportRequest->primary_context_type, + 'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN + ? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null) + : (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null), + 'submitted_at' => $supportRequest->created_at?->toIso8601String(), + 'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY), + 'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference), + 'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url), + 'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary), + 'has_external_link' => $supportRequest->hasExternalTicket(), + 'has_failure' => $supportRequest->hasExternalHandoffFailure(), + ]; } /** @@ -137,10 +313,20 @@ private function submit( * reproduction_notes: ?string, * contact_name: ?string, * contact_email: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, * } */ private function validate(array $data): array { + $requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null) + ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY; + + if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) { + $requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY; + } + $validated = validator( [ 'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL, @@ -148,6 +334,9 @@ private function validate(array $data): array 'reproduction_notes' => $data['reproduction_notes'] ?? null, 'contact_name' => $data['contact_name'] ?? null, 'contact_email' => $data['contact_email'] ?? null, + 'external_handoff_mode' => $requestedHandoffMode, + 'external_ticket_reference' => $data['external_ticket_reference'] ?? null, + 'external_ticket_url' => $data['external_ticket_url'] ?? null, ], [ 'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())], @@ -155,6 +344,9 @@ private function validate(array $data): array 'reproduction_notes' => ['nullable', 'string'], 'contact_name' => ['nullable', 'string'], 'contact_email' => ['nullable', 'email'], + 'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())], + 'external_ticket_reference' => ['nullable', 'string', 'max:255'], + 'external_ticket_url' => ['nullable', 'url', 'max:2048'], ], )->validate(); @@ -169,6 +361,27 @@ private function validate(array $data): array $validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null); $validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null); $validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null); + $validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null); + $validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null); + + if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET + && $validated['external_ticket_reference'] === null) { + throw ValidationException::withMessages([ + 'external_ticket_reference' => 'The external ticket reference field is required.', + ]); + } + + if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) { + $this->externalSupportDeskHandoffService->normalizeLinkedTicket( + $validated['external_ticket_reference'], + $validated['external_ticket_url'], + ); + } + + if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) { + $validated['external_ticket_reference'] = null; + $validated['external_ticket_url'] = null; + } return $validated; } diff --git a/apps/platform/config/support_desk.php b/apps/platform/config/support_desk.php new file mode 100644 index 00000000..5f519478 --- /dev/null +++ b/apps/platform/config/support_desk.php @@ -0,0 +1,14 @@ + [ + 'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false), + 'name' => env('SUPPORT_DESK_NAME', 'External support desk'), + 'create_url' => env('SUPPORT_DESK_CREATE_URL'), + 'api_token' => env('SUPPORT_DESK_API_TOKEN'), + 'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'), + 'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5), + ], +]; diff --git a/apps/platform/database/factories/SupportRequestFactory.php b/apps/platform/database/factories/SupportRequestFactory.php index 39d51edd..b49a4951 100644 --- a/apps/platform/database/factories/SupportRequestFactory.php +++ b/apps/platform/database/factories/SupportRequestFactory.php @@ -51,6 +51,10 @@ public function definition(): array ], 'omissions' => [], ], + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'external_handoff_failure_summary' => null, ]; } diff --git a/apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php b/apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php new file mode 100644 index 00000000..e0f37851 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php @@ -0,0 +1,35 @@ +string('external_handoff_mode') + ->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->after('context_envelope'); + $table->string('external_ticket_reference')->nullable()->after('external_handoff_mode'); + $table->text('external_ticket_url')->nullable()->after('external_ticket_reference'); + $table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url'); + }); + } + + public function down(): void + { + Schema::table('support_requests', function (Blueprint $table): void { + $table->dropColumn([ + 'external_handoff_mode', + 'external_ticket_reference', + 'external_ticket_url', + 'external_handoff_failure_summary', + ]); + }); + } +}; diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index 4827d589..a062fd7e 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -80,14 +80,40 @@ 'request_support' => 'Support anfragen', 'support_request_heading' => 'Support anfragen', 'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.', - 'submit_request' => 'Anfrage senden', + 'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.', + 'submit_request' => 'Supportanfrage senden', + 'primary_context' => 'Primärer Kontext', 'included_context' => 'Enthaltener Kontext', + 'latest_external_handoff' => 'Letzte externe Übergabe', + 'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.', + 'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.', + 'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.', + 'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure', + 'external_handoff_mode' => 'Externe Übergabe', + 'handoff_mode_internal_only' => 'Nur TenantPilot', + 'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen', + 'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen', + 'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.', + 'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.', + 'handoff_mutation_scope' => 'Änderungsumfang', + 'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.', + 'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.', + 'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.', + 'external_ticket_reference' => 'Externe Ticketreferenz', + 'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.', + 'external_ticket_url' => 'Externe Ticket-URL', + 'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.', 'severity' => 'Schweregrad', 'summary' => 'Zusammenfassung', 'reproduction_notes' => 'Reproduktionshinweise', 'contact_name' => 'Kontaktname', 'contact_email' => 'Kontakt-E-Mail', 'support_request_submitted' => 'Supportanfrage gesendet', + 'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.', + 'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.', + 'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.', + 'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure', + 'open_external_ticket' => 'Externes Ticket öffnen', 'open_support_diagnostics' => 'Supportdiagnosen öffnen', 'support_diagnostics' => 'Supportdiagnosen', 'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 8d0869b9..45928412 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -80,14 +80,40 @@ 'request_support' => 'Request support', 'support_request_heading' => 'Request support', 'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.', - 'submit_request' => 'Submit request', + 'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.', + 'submit_request' => 'Submit support request', + 'primary_context' => 'Primary context', 'included_context' => 'Included context', + 'latest_external_handoff' => 'Latest external handoff', + 'latest_external_handoff_none' => 'No support request has been submitted for this context yet.', + 'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.', + 'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.', + 'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure', + 'external_handoff_mode' => 'External handoff', + 'handoff_mode_internal_only' => 'TenantPilot only', + 'handoff_mode_create_external_ticket' => 'Create external ticket', + 'handoff_mode_link_existing_ticket' => 'Link existing ticket', + 'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.', + 'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.', + 'handoff_mutation_scope' => 'Mutation scope', + 'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.', + 'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.', + 'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.', + 'external_ticket_reference' => 'External ticket reference', + 'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.', + 'external_ticket_url' => 'External ticket URL', + 'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.', 'severity' => 'Severity', 'summary' => 'Summary', 'reproduction_notes' => 'Reproduction notes', 'contact_name' => 'Contact name', 'contact_email' => 'Contact email', 'support_request_submitted' => 'Support request submitted', + 'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.', + 'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.', + 'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.', + 'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure', + 'open_external_ticket' => 'Open external ticket', 'open_support_diagnostics' => 'Open support diagnostics', 'support_diagnostics' => 'Support diagnostics', 'support_diagnostics_description' => 'Redacted tenant context from existing records.', diff --git a/apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php b/apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php new file mode 100644 index 00000000..34ce9748 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php @@ -0,0 +1,148 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256RunHandoffComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +function spec256OperationRun(Tenant $tenant): OperationRun +{ + return OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'completed_at' => now(), + ]); +} + +it('creates an external ticket from the operation-run support action', function (): void { + spec256ConfigureRunSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + $run = spec256OperationRun($tenant); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-RUN-256', + ], 201), + ]); + + spec256RunHandoffComponent($user, $run) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_HIGH, + 'summary' => 'Run create external ticket handoff.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN) + ->and($supportRequest->operation_run_id)->toBe((int) $run->getKey()) + ->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-256') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-256'); +}); + +it('links an existing external ticket from the operation-run support action without outbound create', function (): void { + spec256ConfigureRunSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager'); + $run = spec256OperationRun($tenant); + + Http::fake(); + + spec256RunHandoffComponent($user, $run) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Run link existing external ticket.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-RUN-LINK', + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-LINK') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-LINK'); + + Http::assertNothingSent(); +}); + +it('keeps the internal run support request when external create fails', function (): void { + spec256ConfigureRunSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $run = spec256OperationRun($tenant); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + spec256RunHandoffComponent($user, $run) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Run external handoff failure should keep internal support request.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN) + ->and($supportRequest->operation_run_id)->toBe((int) $run->getKey()) + ->and($supportRequest->external_ticket_reference)->toBeNull() + ->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout') + ->and(OperationRun::query()->count())->toBe(1); +}); diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php new file mode 100644 index 00000000..50b3ee79 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php @@ -0,0 +1,140 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256AuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +it('preserves support request created audit and records external ticket created audit', function (): void { + spec256ConfigureAuditSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-AUDIT-CREATED', + 'raw_secret' => 'must-not-be-copied', + ], 201), + ]); + + spec256AuditTenantComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Audit external ticket created.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + $createdAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestCreated->value) + ->sole(); + + $externalAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestExternalTicketCreated->value) + ->sole(); + + expect($createdAudit->resource_id)->toBe((string) $supportRequest->getKey()) + ->and($externalAudit->resource_id)->toBe((string) $supportRequest->getKey()) + ->and($externalAudit->tenant_id)->toBe((int) $tenant->getKey()) + ->and($externalAudit->status)->toBe('success') + ->and(data_get($externalAudit->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference) + ->and(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-CREATED') + ->and((string) json_encode($externalAudit->metadata))->not->toContain('must-not-be-copied'); +}); + +it('records external ticket linked audit without issuing outbound create', function (): void { + spec256ConfigureAuditSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + Http::fake(); + + spec256AuditTenantComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Audit external ticket linked.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-AUDIT-LINKED', + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $externalAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestExternalTicketLinked->value) + ->sole(); + + expect(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-LINKED') + ->and($externalAudit->status)->toBe('success'); + + Http::assertNothingSent(); +}); + +it('records external handoff failed audit with bounded failure metadata', function (): void { + spec256ConfigureAuditSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + spec256AuditTenantComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Audit external ticket failure.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + $externalAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestExternalHandoffFailed->value) + ->sole(); + + expect($externalAudit->resource_id)->toBe((string) $supportRequest->getKey()) + ->and($externalAudit->status)->toBe('failed') + ->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBeNull() + ->and(data_get($externalAudit->metadata, 'external_handoff_failure_summary'))->toContain('configured timeout'); +}); diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php new file mode 100644 index 00000000..fe2afd64 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php @@ -0,0 +1,131 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function spec256AuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +function spec256AuthorizationRun(Tenant $tenant): OperationRun +{ + return OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now(), + ]); +} + +it('keeps external handoff actions forbidden for entitled tenant members without support-create capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + spec256AuthorizationTenantComponent($user, $tenant) + ->assertActionVisible('requestSupport') + ->assertActionDisabled('requestSupport') + ->call('authorizeTenantSupportRequest') + ->assertForbidden(); + + expect(SupportRequest::query()->count())->toBe(0); +}); + +it('keeps external handoff actions forbidden for entitled run viewers without support-create capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $run = spec256AuthorizationRun($tenant); + + spec256AuthorizationOperationComponent($user, $run) + ->assertActionVisible('requestSupport') + ->assertActionDisabled('requestSupport') + ->call('authorizeOperationRunSupportRequest') + ->assertForbidden(); + + expect(SupportRequest::query()->count())->toBe(0); +}); + +it('does not reveal latest tenant handoff summaries to workspace members without tenant entitlement', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'operator', + ]); + + SupportRequest::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT, + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-HIDDEN', + ]); + + try { + app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user); + $this->fail('Expected latest handoff summary to deny as not found.'); + } catch (HttpExceptionInterface $exception) { + expect($exception->getStatusCode())->toBe(404); + } +}); + +it('does not reveal latest run handoff summaries outside the run tenant entitlement', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = spec256AuthorizationRun($tenant); + + SupportRequest::factory() + ->forOperationRun($run) + ->create([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-RUN-HIDDEN', + ]); + + try { + app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($run, $user); + $this->fail('Expected latest run handoff summary to deny as not found.'); + } catch (HttpExceptionInterface $exception) { + expect($exception->getStatusCode())->toBe(404); + } +}); diff --git a/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php new file mode 100644 index 00000000..3a5b4579 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php @@ -0,0 +1,187 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256TenantHandoffComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +it('creates an external ticket from the tenant dashboard support action', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(['name' => 'Spec 256 Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-2561', + 'ticket_url' => 'https://desk.example.test/tickets/PSA-2561', + ], 201), + ]); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_HIGH, + 'summary' => 'Tenant create external ticket handoff.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/') + ->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-2561') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-2561') + ->and($supportRequest->external_handoff_failure_summary)->toBeNull() + ->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets' + && data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference); +}); + +it('links an existing external ticket from the tenant dashboard without creating a duplicate external ticket', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + Http::fake(); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_NORMAL, + 'summary' => 'Tenant link existing external ticket.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-256-LINK', + 'external_ticket_url' => 'https://desk.example.test/tickets/PSA-256-LINK', + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-256-LINK') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-256-LINK') + ->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED); + + Http::assertNothingSent(); +}); + +it('rejects invalid linked external ticket input before storing a tenant support request', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + Http::fake(); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_NORMAL, + 'summary' => 'Tenant invalid link should not create support truth.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'not a ticket', + ]) + ->callMountedAction() + ->assertHasErrors(['external_ticket_reference']); + + expect(SupportRequest::query()->count())->toBe(0); + + Http::assertNothingSent(); +}); + +it('keeps the internal tenant support request when external create fails', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_BLOCKING, + 'summary' => 'Tenant external desk timeout should keep internal support request.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->internal_reference)->toMatch('/^SR-/') + ->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and($supportRequest->external_ticket_reference)->toBeNull() + ->and($supportRequest->external_ticket_url)->toBeNull() + ->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout') + ->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED); +}); + +it('forces tenant support requests to internal only when no external target is configured', function (): void { + spec256ConfigureTenantSupportDesk([ + 'enabled' => false, + ]); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Http::fake(); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Tenant support stays internal when no support desk target exists.', + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->and($supportRequest->external_ticket_reference)->toBeNull() + ->and($supportRequest->external_handoff_failure_summary)->toBeNull(); + + Http::assertNothingSent(); +}); diff --git a/apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php b/apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php new file mode 100644 index 00000000..3836287f --- /dev/null +++ b/apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php @@ -0,0 +1,121 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256SupportRequest(array $attributes = []): SupportRequest +{ + $tenant = Tenant::factory()->create(); + + return SupportRequest::factory()->create(array_merge([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'summary' => 'Need external support desk handoff.', + 'severity' => SupportRequest::SEVERITY_HIGH, + ], $attributes)); +} + +it('creates an external ticket through the configured target and normalizes the returned reference', function (): void { + configureSpec256SupportDesk([ + 'api_token' => 'secret-token', + ]); + + $supportRequest = spec256SupportRequest(); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-12345', + 'ticket_url' => 'https://desk.example.test/tickets/PSA-12345', + ], 201), + ]); + + $result = app(ExternalSupportDeskHandoffService::class)->createTicket($supportRequest); + + expect($result['successful'])->toBeTrue() + ->and($result['external_ticket_reference'])->toBe('PSA-12345') + ->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-12345') + ->and($result['failure_summary'])->toBeNull(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets' + && data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference + && data_get($request->data(), 'support_request.summary') === 'Need external support desk handoff.'); +}); + +it('enforces the five second timeout budget and normalizes connection failures', function (): void { + configureSpec256SupportDesk([ + 'timeout_seconds' => 30, + ]); + + $supportRequest = spec256SupportRequest(); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + $service = app(ExternalSupportDeskHandoffService::class); + $result = $service->createTicket($supportRequest); + + expect($service->timeoutSeconds())->toBe(5) + ->and($result['successful'])->toBeFalse() + ->and($result['external_ticket_reference'])->toBeNull() + ->and($result['failure_summary'])->toContain('configured timeout'); +}); + +it('falls back to unavailable when the single configured target is disabled', function (): void { + configureSpec256SupportDesk([ + 'enabled' => false, + ]); + + Http::fake(); + + $service = app(ExternalSupportDeskHandoffService::class); + $result = $service->createTicket(spec256SupportRequest()); + + expect($service->targetIsConfigured())->toBeFalse() + ->and($result['successful'])->toBeFalse() + ->and($result['failure_summary'])->toBe('External support desk target is not configured.'); + + Http::assertNothingSent(); +}); + +it('normalizes linked tickets without issuing an outbound create call', function (): void { + configureSpec256SupportDesk(); + Http::fake(); + + $result = app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket(' PSA-900 ', null); + + expect($result['external_ticket_reference'])->toBe('PSA-900') + ->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-900'); + + Http::assertNothingSent(); +}); + +it('rejects invalid linked ticket input before storing external truth', function (): void { + configureSpec256SupportDesk(); + + expect(fn (): array => app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket('not a ticket', 'javascript:alert(1)')) + ->toThrow(ValidationException::class); +}); diff --git a/apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php b/apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php new file mode 100644 index 00000000..df3fed6f --- /dev/null +++ b/apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php @@ -0,0 +1,113 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + SupportRequest::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT, + 'internal_reference' => 'SR-OLDTENANT0000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-OLD', + 'created_at' => now()->subMinutes(10), + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'created_at' => now(), + ]); + + SupportRequest::factory() + ->forOperationRun($run) + ->create([ + 'internal_reference' => 'SR-RUN000000000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-RUN', + 'created_at' => now(), + ]); + + SupportRequest::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT, + 'internal_reference' => 'SR-NEWTENANT0000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + 'external_handoff_failure_summary' => 'External support desk did not respond before the configured timeout.', + 'created_at' => now()->subMinute(), + ]); + + $summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user); + + expect($summary)->not->toBeNull() + ->and($summary['internal_reference'])->toBe('SR-NEWTENANT0000000000000001') + ->and($summary['has_failure'])->toBeTrue() + ->and($summary['has_external_link'])->toBeFalse() + ->and($summary['external_handoff_failure_summary'])->toContain('configured timeout'); +}); + +it('returns the latest run-scoped handoff summary for the current run only', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $firstRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + ]); + + $secondRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + ]); + + SupportRequest::factory() + ->forOperationRun($secondRun) + ->create([ + 'internal_reference' => 'SR-OTHERRUN0000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-OTHER', + 'created_at' => now(), + ]); + + SupportRequest::factory() + ->forOperationRun($firstRun) + ->create([ + 'internal_reference' => 'SR-CURRENTRUN0000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-CURRENT', + 'external_ticket_url' => 'https://desk.example.test/tickets/PSA-CURRENT', + 'created_at' => now()->subMinute(), + ]); + + $summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($firstRun, $user); + + expect($summary)->not->toBeNull() + ->and($summary['internal_reference'])->toBe('SR-CURRENTRUN0000000000001') + ->and($summary['external_ticket_reference'])->toBe('PSA-CURRENT') + ->and($summary['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-CURRENT') + ->and($summary['has_external_link'])->toBeTrue(); +}); diff --git a/specs/256-external-support-desk-handoff/checklists/requirements.md b/specs/256-external-support-desk-handoff/checklists/requirements.md new file mode 100644 index 00000000..a1142c29 --- /dev/null +++ b/specs/256-external-support-desk-handoff/checklists/requirements.md @@ -0,0 +1,63 @@ +# Preparation Review Checklist: External Support Desk / PSA Handoff + +**Purpose**: Validate the prepared support-handoff package against the repo's guardrail, support-truth, provider-boundary, scoped-visibility, and close-out workflow requirements before implementation +**Created**: 2026-04-29 +**Feature**: [spec.md](../spec.md) + +## Applicability And Low-Impact Gate + +- [x] CHK001 The package explicitly treats this as an operator-facing extension on two existing support-aware actions, so the low-impact `N/A` path is not used. +- [x] CHK002 The spec, plan, tasks, and supporting artifacts carry the same bounded slice: existing `SupportRequest` truth stays authoritative, visibility stays on the current tenant or run support contexts only, handoff remains one-way, one configured target is allowed, and the close-out target remains `Guardrail / Exception / Smoke Coverage`. + +## Native, Shared-Family, And State Ownership + +- [x] CHK003 The primary surfaces remain native Filament actions on `TenantDashboard` and `TenantlessOperationRunViewer` instead of a support-request resource, support queue, helpdesk shell, or standalone external-desk page. +- [x] CHK004 Shared support families remain shared: the internal `SR-...` support request stays the canonical truth, the latest handoff summary stays attached to the same two support actions, and the package does not invent a parallel support history or ticket-register surface. +- [x] CHK005 Page, detail, action-form, and persisted state owners are named once: `support_requests` is the only planned persisted truth, while the tenant and run pages own only current-context presentation and submit-time form state. +- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: the operator uses the existing `Request support` action, chooses one handoff mode inside that form, and does not branch into a second workflow family. + +## Shared Pattern Reuse + +- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-aware action surfaces. +- [x] CHK008 The package extends existing shared paths where they are sufficient, and the only allowed deviation is one concrete provider-owned handoff service plus one tiny latest-summary read helper if implementation proves it necessary, not a generic helpdesk registry or page-local HTTP path. +- [x] CHK009 The package does not create a parallel operator UX language; `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk` stay consistent across tenant, run, notification, and audit wording. + +## OperationRun Start UX Contract + +- [x] CHK019 The package explicitly states that the run surface uses the current `OperationRun` only as support context and does not create, queue, deduplicate, resume, block, complete, or deep-link to a run workflow as part of the handoff slice. +- [x] CHK020 Run-specific workflow contracts stay on the existing canonical run page; queued toast/link/browser-event/dedupe behavior is not reintroduced locally for support handoff. +- [x] CHK021 No queued DB notification or terminal-notification path is added because the slice stays synchronous inside the current support-request submit path. +- [x] CHK022 No `OperationRun` exception is required in the preparation package; if implementation later adds retries, queueing, or run-orchestration semantics, that must be recorded as out-of-scope drift in the active close-out entry. + +## Provider Boundary And Vocabulary + +- [x] CHK010 Provider-specific semantics stay behind one concrete provider-owned handoff service and one preconfigured target-resolution seam; the planned persisted truth stays neutral on `SupportRequest` with handoff mode, external reference, external URL, and bounded failure summary only. +- [x] CHK011 No retained provider-specific shared boundary or second-target abstraction is introduced; multi-provider support, target-management UI, and broader ITSM or helpdesk modeling remain follow-up-spec work only. + +## Signals, Exceptions, And Test Depth + +- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`: the package adds a new provider seam and new persisted fields, but it does so on the existing support-request truth without hidden queue, resource, or support-framework drift. +- [x] CHK013 One bounded contract exception is explicit in the preparation package: Spec 256 allows exactly one synchronous finalization write on the same `SupportRequest` row after internal creation, limited to external handoff fields only. Any wider mutability, retry orchestration, or support-history spread must still be documented in the active feature close-out entry instead of becoming silent scope growth. +- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the tenant dashboard action, `monitoring-state-page` for the run action, and focused `Unit` plus `Feature` proof for handoff branching, scoped summary reuse, authorization, and audit behavior. +- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused Pest unit plus feature coverage with narrow manual smoke after implementation, and no implicit browser-only, global-search, or new resource coverage obligation is invented. + +## Audience-Aware Disclosure And Decision Hierarchy + +- [x] CHK023 Default-visible content stays decision-first: current support context, one handoff-mode decision, one latest bounded handoff summary, and one submit action remain primary. +- [x] CHK024 The package keeps raw/provider-heavy material out of default-visible truth: no raw payloads, credentials, provider response bodies, assignee or SLA fields, retry status, or cross-scope lookup shortcuts are allowed into the support-request row or default UI copy. +- [x] CHK025 Exactly one dominant next action remains primary: `Submit support request`; external create or link is modeled as a form choice, not as a competing primary action or second workflow entry point. +- [x] CHK026 Duplicate visible truth is avoided by naming one internal support reference and one latest context-scoped handoff summary instead of introducing a ticket history block, queue summary, or separate support register surface. +- [x] CHK027 Support or raw detail stays hidden or provider-owned, and latest handoff visibility remains bounded to the same entitled tenant or current run context with the existing `404` versus `403` rules. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` should record target-prerequisite status, any bounded implementation exception, and the final proof or smoke outcome. + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, and the conceptual contract. It does not claim application code exists. +- The slice remains bounded to the existing support-request truth and the two existing support-aware actions only. No support-request resource, support queue, helpdesk framework, global-search surface, or `OperationRun` workflow is approved by this package. +- Preparation note: the package now makes the single-target resolution seam explicit through `apps/platform/config/support_desk.php` and keeps workspace settings UI, per-workspace target management, second-target support, and retry or relink orchestration as later follow-up scope. +- Preparation note: Spec 256 explicitly narrows Spec 246 immutability for one synchronous handoff-finalization write only; no broader edit, reopen, merge, or lifecycle workflow is approved by this package. \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml b/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml new file mode 100644 index 00000000..972a00c0 --- /dev/null +++ b/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml @@ -0,0 +1,216 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin — External Support Desk Handoff (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the first external support desk handoff slice. + + NOTE: These flows are implemented as Filament (Livewire) actions on + existing pages. This file captures the expected action payload, outcome + semantics, and authorization boundaries rather than a public REST API. +servers: + - url: /admin +paths: + /t/{tenant}/support-requests/actions/submit: + post: + summary: Submit a tenant-context support request with optional external handoff + description: | + Existing tenant dashboard support action, extended with one-way external + handoff behavior. + + Authorization: + - Workspace non-member or non-entitled tenant actor: 404 + - Entitled tenant member without `support_requests.create`: 403 + - Authorized actor: 200 with one support-request submission result + + Behavior: + - Always creates the internal `SR-...` support request first + - `internal_only` performs no outbound handoff + - `link_existing_ticket` stores the provided external reference and must not call external create + - `create_external_ticket` uses one application-configured external target only + - `create_external_ticket` applies a maximum 5 second outbound timeout budget + - External create failure keeps the internal support request and returns an explicit failed-handoff outcome + - No queue, `OperationRun`, retry scheduler, or bidirectional sync is introduced + parameters: + - name: tenant + in: path + required: true + schema: + type: string + description: Filament tenancy slug (`tenants.external_id`) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffSubmission' + responses: + '200': + description: Support request accepted with internal-only, linked, created, or failed-handoff outcome + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffResult' + '403': + description: Forbidden (entitled tenant member lacks support-request capability) + '404': + description: Not found (wrong workspace, non-member, or missing tenant entitlement) + /operations/{run}/support-requests/actions/submit: + post: + summary: Submit a run-context support request with optional external handoff + description: | + Existing canonical run detail support action, extended with one-way + external handoff behavior. + + Authorization: + - Inaccessible run or run outside entitled tenant scope: 404 + - Entitled member without `support_requests.create`: 403 + - Authorized actor: 200 with one support-request submission result + + Behavior: + - The run must resolve to an entitled tenant before any support truth is revealed + - Uses the same payload contract and outcome semantics as the tenant-context action + - Does not create, resume, or update an `OperationRun` + parameters: + - name: run + in: path + required: true + schema: + type: integer + description: Internal `operation_runs.id` + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffSubmission' + responses: + '200': + description: Support request accepted with internal-only, linked, created, or failed-handoff outcome + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffResult' + '403': + description: Forbidden (entitled member lacks support-request capability) + '404': + description: Not found (run inaccessible under workspace or tenant scope) +components: + schemas: + SupportRequestHandoffSubmission: + type: object + required: + - severity + - summary + - handoff_mode + properties: + severity: + type: string + enum: [low, normal, high, blocking] + summary: + type: string + reproduction_notes: + type: string + nullable: true + contact_name: + type: string + nullable: true + contact_email: + type: string + format: email + nullable: true + handoff_mode: + type: string + enum: [internal_only, create_external_ticket, link_existing_ticket] + external_ticket_reference: + type: string + nullable: true + description: Required when `handoff_mode = link_existing_ticket` + external_ticket_url: + type: string + format: uri + nullable: true + target_available: + type: boolean + nullable: true + description: Derived UI hint only; the server remains authoritative + SupportRequestHandoffResult: + type: object + required: + - support_request_id + - internal_reference + - primary_context_type + - handoff_mode + - handoff_outcome + - latest_summary + properties: + support_request_id: + type: integer + internal_reference: + type: string + primary_context_type: + type: string + enum: [tenant, operation_run] + primary_context_id: + type: integer + nullable: true + handoff_mode: + type: string + enum: [internal_only, create_external_ticket, link_existing_ticket] + handoff_outcome: + type: string + enum: + - internal_only + - external_ticket_created + - external_ticket_linked + - external_handoff_failed + external_ticket_reference: + type: string + nullable: true + external_ticket_url: + type: string + format: uri + nullable: true + failure_summary: + type: string + nullable: true + latest_summary: + $ref: '#/components/schemas/LatestSupportRequestHandoffSummary' + LatestSupportRequestHandoffSummary: + type: object + required: + - internal_reference + - primary_context_type + - submitted_at + - handoff_mode + - has_external_link + - has_failure + properties: + internal_reference: + type: string + primary_context_type: + type: string + enum: [tenant, operation_run] + primary_context_id: + type: integer + nullable: true + submitted_at: + type: string + format: date-time + handoff_mode: + type: string + enum: [internal_only, create_external_ticket, link_existing_ticket] + external_ticket_reference: + type: string + nullable: true + external_ticket_url: + type: string + format: uri + nullable: true + external_handoff_failure_summary: + type: string + nullable: true + has_external_link: + type: boolean + has_failure: + type: boolean \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/data-model.md b/specs/256-external-support-desk-handoff/data-model.md new file mode 100644 index 00000000..b4fc3f95 --- /dev/null +++ b/specs/256-external-support-desk-handoff/data-model.md @@ -0,0 +1,161 @@ +# Data Model — External Support Desk / PSA Handoff + +**Spec**: [spec.md](spec.md) + +Spec 256 extends the existing support-request truth. No new support-ticket table, resource, or queue artifact is introduced. + +## Existing Canonical Entity Extended + +### SupportRequest (`support_requests`) + +**Purpose**: Canonical tenant-owned support-request truth. Spec 256 extends it so the same row can carry one-way external handoff continuity. + +**Existing key fields (already in repo)**: +- `id` +- `workspace_id` +- `tenant_id` +- `operation_run_id` +- `initiated_by_user_id` +- `internal_reference` +- `primary_context_type` +- `attachment_mode` +- `severity` +- `summary` +- `reproduction_notes` +- `contact_name` +- `contact_email` +- `context_envelope` +- `created_at` +- `updated_at` + +**New fields (planned)**: +- `external_handoff_mode` + - type: string + - required: yes + - default: `internal_only` + - allowed values: + - `internal_only` + - `create_external_ticket` + - `link_existing_ticket` +- `external_ticket_reference` + - type: nullable string + - stored when an external ticket was created or linked successfully +- `external_ticket_url` + - type: nullable text + - stored only when the target returns or the operator provides a valid URL +- `external_handoff_failure_summary` + - type: nullable text + - bounded human-readable failure summary for the current request only + +**Relationships (unchanged)**: +- belongs to `Workspace` +- belongs to `Tenant` +- optionally belongs to `OperationRun` +- optionally belongs to initiator `User` + +**Behavioral rules**: +- `internal_reference` remains the canonical TenantPilot support identifier even when an external ticket exists. +- `external_handoff_mode` records the operator’s chosen path and replaces the need for a second persisted status family. +- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request is created, the same row may receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No later edit, reopen, merge, or status workflow is introduced. +- `external_ticket_reference` and `external_ticket_url` remain null for `internal_only` and for failed create attempts. +- `external_handoff_failure_summary` remains null on successful create, successful link, and internal-only submissions. +- On a failed external create, the row persists with: + - `external_handoff_mode = create_external_ticket` + - `external_ticket_reference = null` + - `external_ticket_url = null` + - `external_handoff_failure_summary` populated +- When the failed external create was caused by timeout, `external_handoff_failure_summary` stores the same bounded timeout-oriented message that the UI and audit path use. Raw transport detail is never persisted. + +**Latest-summary query rules**: +- Tenant dashboard summary queries the latest support request for the current entitled tenant where `primary_context_type = tenant`. +- Operation-run summary queries the latest support request for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run. +- Existing indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient. No new lookup path by external reference is planned. + +**Validation rules**: +- `external_handoff_mode` must be one of the three allowed values. +- `external_ticket_reference` is required when `external_handoff_mode = link_existing_ticket`. +- `external_ticket_url` is optional but must be a valid URL when present. +- When no external target is configured for the application, the form must force or constrain the effective mode to `internal_only`. + +## Application-Configured External Target (Config Contract In Scope, Not New Persisted Truth) + +### External Support Desk Target + +**Purpose**: Supplies the one configured outbound target for create or link normalization. + +**Status in Spec 256**: +- minimal application config contract in scope +- not a new persisted entity in this slice +- not a workspace settings domain or UI surface in this slice + +**Repo-grounded note**: +- The repo has no existing `support` settings domain, so Spec 256 makes the target seam explicit through one application config file: `apps/platform/config/support_desk.php` with environment-backed values for the single supported target. +- This config contract may define availability, create endpoint settings, reference-link normalization defaults, and the five-second outbound timeout budget. +- Per-workspace target selection, settings UI, or a second target remain follow-up scope. + +## Derived Runtime Entities + +### SupportRequestHandoffOutcome (computed, not persisted) + +**Purpose**: Gives the Filament page actions one normalized outcome for notification copy and tests after submission completes. + +**Expected shape**: +- `support_request_id` +- `internal_reference` +- `primary_context_type` +- `handoff_mode` +- `handoff_outcome` + - `internal_only` + - `external_ticket_created` + - `external_ticket_linked` + - `external_handoff_failed` +- `external_ticket_reference` +- `external_ticket_url` +- `failure_summary` + +**Why derived only**: +- The outcome is an execution summary for one request cycle. +- Persisting it separately would duplicate the support-request truth and audit log. +- The bounded synchronous finalization write on `SupportRequest` remains the only allowed post-create mutation for this slice. + +### LatestSupportRequestHandoffSummary (computed, not persisted) + +**Purpose**: Supplies the existing tenant and run support actions with one scoped summary of the latest linkage for the current primary context. + +**Expected shape**: +- `internal_reference` +- `primary_context_type` +- `primary_context_id` +- `submitted_at` +- `external_handoff_mode` +- `external_ticket_reference` +- `external_ticket_url` +- `external_handoff_failure_summary` +- `has_external_link` +- `has_failure` + +**Why derived only**: +- It is a read model over the latest `support_requests` row for one context. +- A separate table or persisted summary would violate `PERSIST-001` without solving a distinct lifecycle problem. + +## Audit Events (Persistent Audit Truth, Not Product Truth) + +The implementation should add these stable audit actions in addition to the existing `support_request.created` event: + +- `support_request.external_ticket_created` +- `support_request.external_ticket_linked` +- `support_request.external_handoff_failed` + +**Audit context should include**: +- `workspace_id` +- `tenant_id` +- `internal_reference` +- `primary_context_type` +- `primary_context_id` +- `external_handoff_mode` +- `external_ticket_reference` when present + +**Audit context should not include**: +- raw provider request payloads +- secrets or credentials +- unrestricted provider response bodies \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/plan.md b/specs/256-external-support-desk-handoff/plan.md new file mode 100644 index 00000000..b8267627 --- /dev/null +++ b/specs/256-external-support-desk-handoff/plan.md @@ -0,0 +1,319 @@ +# Implementation Plan: External Support Desk / PSA Handoff + +**Branch**: `256-external-support-desk-handoff` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` + +## Summary + +- Extend the existing support-request submission flow so the two current support-aware surfaces can keep a request internal-only, create one external desk ticket, or link one existing external ticket without adding a new support product surface. +- Persist only the minimal neutral linkage truth on the existing `support_requests` row: `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. +- Keep the flow synchronous and auditable inside the existing support-request path: create the internal `SR-...` request first, allow exactly one bounded synchronous finalization write for external create, link, or failure fields on the same row, enforce a five-second outbound timeout on the create path, and surface the latest linkage summary only in the current tenant or run support context. + +## Technical Context + +**Language/Version**: PHP 8.4 on Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and `CapabilityResolver` +**Storage**: PostgreSQL; extend the tenant-owned `support_requests` table, keep `workspace_id` and `tenant_id` required, and do not add a second support-ticket table +**Testing**: Pest unit + feature tests +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}` +**Project Type**: web +**Performance Goals**: keep the submit path synchronous, apply a maximum five-second outbound timeout on the create path, and avoid queue or `OperationRun` overhead +**Constraints**: one application-configured external target only, no new support-request resource/list/detail page, no global-search surface, no bidirectional sync, no retry scheduler, no raw provider payload persistence, no provider registration changes, and no runtime asset changes +**Scale/Scope**: one additive migration on `support_requests`, one concrete provider-owned handoff service, one small derived latest-summary helper or equivalent shared read path, two Filament action-form extensions, audit additions, and focused unit plus feature coverage only + +## Key Design Decisions + +### Persistence and source of truth + +- `support_requests` is the only persisted truth for this slice. No `support_tickets` table, no queue artifact, and no new support page model is justified. +- The plan adds these columns to `support_requests`: + - `external_handoff_mode` as a non-null string with default `internal_only` + - `external_ticket_reference` as a nullable string + - `external_ticket_url` as a nullable text field + - `external_handoff_failure_summary` as a nullable text field +- The plan does not add `external_handoff_status`, `external_target_type`, `external_target_id`, raw payload JSON, or a dedicated failure timestamp. Those are not needed for the current operator contract because: + - the handoff mode already captures operator intent + - success is derivable from `external_ticket_reference` + - failure visibility only needs a bounded summary on revisit + - audit timestamps already provide exact event timing +- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request row exists, the same row may receive exactly one synchronous finalization write limited to the external handoff fields above. After that finalization step, the row is immutable again. +- Existing `support_requests` indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient for latest-summary lookups. No external-reference index is planned because cross-scope lookup by external ticket reference is explicitly out of scope. + +### Failure truth and auditable outcomes + +- External create failure is not audit-only. A bounded failure summary must be persisted back on the same `support_requests` row so the current support context can show the last failure on revisit. +- Timeout is treated as the same failure family as any other create failure. The provider-owned service must enforce the five-second outbound timeout budget and return a normalized bounded failure summary rather than raw transport details. +- Detailed provider-specific error payloads remain out of persisted product truth. They stay confined to the provider-owned handoff service, log redaction rules, and audit metadata where appropriate. +- The internal support request remains durable even when external create fails. The implementation must therefore split the flow into: + 1. authorize and validate the existing request + 2. persist the internal support request and `support_request.created` audit event + 3. perform link or create handoff work after the internal row exists + 4. perform the one allowed synchronous finalization write back to the same row and emit the corresponding audit event + +### Visible linkage stays inside existing support contexts only + +- External ticket references do not become a new dashboard card, run section, support history block, global search result, or Filament resource. +- The narrowest correct visibility path is: + - success or partial-success notification immediately after submit + - a latest-handoff summary placeholder inside the existing `Request support` slide-over on `TenantDashboard` + - the same latest-handoff summary placeholder inside the grouped `Request support` slide-over on `TenantlessOperationRunViewer` +- Tenant context summary scopes to the latest support request where `primary_context_type = tenant` for the current entitled tenant. +- Run context summary scopes to the latest support request where `primary_context_type = operation_run` and `operation_run_id` matches the currently opened run. + +### Minimal application config contract is in scope; support settings UI is not + +- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation drift. +- This plan therefore brings one minimal application config contract into scope: `apps/platform/config/support_desk.php` backed by environment values for the single supported target. +- The implementation may resolve availability, create endpoint configuration, and timeout settings from that config file only. +- This spec still forbids workspace settings UI, a new settings domain, per-workspace target management, provider-connection product work, or multi-target support. + +### Timeout and latency rule + +- The one application-configured create path must use a maximum five-second outbound timeout. +- A timeout is normalized into the same bounded failure-summary and audit path as any other external create failure. +- The timeout budget is part of the feature contract and must be covered by the handoff-service unit tests. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament actions plus shared support primitives +- **Shared-family relevance**: header actions, grouped detail actions, support-request slide-overs, success or warning notifications, latest-handoff summaries, and external-link navigation +- **State layers in scope**: page, detail, action form +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first support form, diagnostics-second through the existing neighboring diagnostics action, provider/raw evidence third and hidden +- **Raw/support gating plan**: provider-specific payloads, secrets, and raw responses stay provider-owned and hidden; only bounded human-readable linkage or failure summary becomes default-visible +- **One-primary-action / duplicate-truth control**: the dominant action remains `Submit support request`; handoff choice is a form field, not a second primary action, and the visible summary names one internal support reference so the surface does not become a history register +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract, manual smoke after implementation +- **Exception path and spread control**: the tenant dashboard keeps its existing bounded action-surface exception; the run viewer keeps both support actions grouped under `More` and does not add a new top-level support action family +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `apps/platform/app/Filament/Pages/TenantDashboard.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Models/SupportRequest.php` + - `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` + - `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php` + - `apps/platform/app/Support/SupportRequests/SupportRequestReferenceGenerator.php` + - `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` + - `apps/platform/app/Support/Audit/AuditActionId.php` + - `apps/platform/database/factories/SupportRequestFactory.php` + - `apps/platform/config/support_desk.php` + - `apps/platform/lang/en/localization.php` + - `apps/platform/lang/de/localization.php` +- **Shared abstractions reused**: existing support-request submission path, existing redacted context builder, existing internal reference generator, existing audit logger, and existing `UiEnforcement` capability gating +- **New abstraction introduced? why?**: one concrete provider-owned external handoff service is justified because both existing surfaces must call or normalize one real external target without page-local HTTP logic; one tiny shared latest-summary read helper is allowed if needed to avoid duplicating the same context-scoped query and copy twice +- **Why the existing abstraction was sufficient or insufficient**: the existing abstractions already solve context capture, internal request creation, and audit logging, but they stop at internal persistence and cannot yet persist external linkage or explicit handoff failure truth +- **Bounded deviation / spread control**: no interface registry, no adapter catalog, no support-desk framework, no second persistence model, and no new support history vocabulary + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: the run viewer uses the current run only as support context and as the scoping key for its latest-handoff summary; it does not create, resume, or link an `OperationRun` +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: outbound create payload, authentication, target-specific reference normalization, URL normalization, and remote error parsing +- **Platform-core seams**: `SupportRequest`, internal support reference, external ticket reference and URL, handoff mode, latest-handoff summary, and bounded failure summary +- **Neutral platform terms / contracts preserved**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk` +- **Retained provider-specific semantics and why**: provider-specific ticket identifiers, auth requirements, and request payload shape remain inside one concrete provider-owned service because the current release has exactly one real external target +- **Bounded extraction or follow-up path**: multi-provider support, target-management UI, and broader ITSM modeling remain follow-up-spec work only + +## Constitution Check + +*GATE: Passed against repo truth before artifact write. Re-checked after Phase 1 design artifacts were drafted.* + +- Inventory-first / snapshots-second: PASS. The slice does not alter inventory or snapshot truth. +- Read/write separation: PASS. The mutation remains an explicit operator submit action with auditable outcomes and planned tests. +- Graph contract path: PASS. No Microsoft Graph calls are introduced. +- Deterministic capabilities: PASS. Capability checks stay on `Capabilities::SUPPORT_REQUESTS_CREATE`; no raw capability strings or role-string checks are planned. +- RBAC-UX / workspace isolation / tenant isolation: PASS. Non-members or actors outside workspace or tenant scope remain `404`; in-scope members missing the capability remain `403`; latest-handoff visibility uses the same boundary as submit. +- Global search safety: PASS. No new Filament resource or globally searchable surface is introduced. +- Run observability / Ops UX: PASS. The slice is intentionally synchronous and does not add queue work or `OperationRun` usage. +- Proportionality / `PROP-001`, `ABSTR-001`, `PERSIST-001`, `STATE-001`, `BLOAT-001`: PASS. The only new persisted truth is four bounded columns on an existing canonical row, one small handoff mode family, and one concrete provider-owned service for one real target. +- Shared pattern reuse / `XCUT-001`: PASS. The plan extends the existing support-request service and existing support-aware action surfaces instead of creating page-local handoff logic. +- Provider boundary / `PROV-001`: PASS. Provider semantics stay confined to the concrete handoff service; platform truth remains neutral. +- Filament-native UI / `UI-FIL-001`: PASS. The flow stays inside native Filament action forms and notifications. +- Livewire v4 / Filament v5 compliance: PASS. The plan stays on the current Filament v5 and Livewire v4 stack. +- Provider registration location: PASS. No provider registration changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. +- Destructive action confirmation: PASS. No destructive action is added, so no new `->requiresConfirmation()` path is introduced. +- Asset strategy: PASS. No new panel or shared assets are required; deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged. +- Test governance / `TEST-GOV-001`: PASS. Proof remains in focused unit plus feature lanes, with manual smoke only as implementation close-out. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for handoff branching, target-unavailable fallback, provider normalization, and latest-summary derivation; Feature for tenant and run action behavior, authorization boundaries, persisted linkage truth, partial-success feedback, and audit events +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and synchronous; business truth lives in the submission service, persistence, and authorization boundaries, so browser automation would mostly duplicate what Pest can already prove through Livewire and domain tests +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, tenant, operation run, user membership, and support-request fixtures; add only a small fake for the one external target and a narrow latest-summary assertion helper if needed +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament relief applies on the tenant dashboard action; the run viewer remains under its monitoring-state-page contract and needs the same tenant-entitlement checks as the current support action +- **Closing validation and reviewer handoff**: re-run the exact unit and feature commands above, then manually smoke create, link, and failure handling from both existing surfaces; reviewers should explicitly verify that no support-request resource, queue, settings UI, or global-search behavior was added +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep +- **Review-stop questions**: did implementation add a new support table, a support-request resource, a support settings UI, a multi-provider registry, or queue or `OperationRun` behavior that the spec forbids? +- **Escalation path**: reject-or-split if target-configuration management, multi-provider support, or retry orchestration appears during implementation +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: the delivery cost stays local to the existing support-request path; broader configuration or multi-provider expansion is separate work, not latent scope inside this slice + +## Implementation Close-Out — Guardrail / Exception / Smoke Coverage + +- **Guardrail outcome**: PASS. The implementation extends only the existing tenant-dashboard and operation-run `Request support` actions, keeps the run support action grouped under `More`, and does not add a support-request resource, support queue, global-search surface, target-management UI, provider registry, or new `OperationRun` behavior. +- **Finalization exception outcome**: PASS. The only post-create mutation on `support_requests` is the Spec 256 bounded finalization write to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`; invalid linked-ticket input is rejected before the internal support request is created. +- **Smoke coverage outcome**: PASS. A temporary Pest Browser smoke harness loaded the tenant dashboard and run detail, submitted tenant `create_external_ticket`, submitted run `link_existing_ticket`, forced run create failure, reopened the run support action to verify the latest failure summary, and asserted no browser console or JavaScript errors. The temporary browser test was removed after execution so the permanent coverage remains the planned unit plus feature lanes. +- **Follow-up decision**: No in-scope follow-up spec is required. Target-management UI, retry/relink workflows, and multi-provider support remain explicit future-spec candidates only if product pressure proves them necessary. + +## Project Structure + +### Documentation (this feature) + +```text +specs/256-external-support-desk-handoff/ +├── checklists/ +│ └── requirements.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── external-support-desk-handoff.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ └── Pages/ +│ │ ├── Operations/ +│ │ │ └── TenantlessOperationRunViewer.php +│ │ └── TenantDashboard.php +│ ├── Models/ +│ │ └── SupportRequest.php +│ ├── Services/ +│ │ └── Audit/ +│ │ └── WorkspaceAuditLogger.php +│ └── Support/ +│ ├── Audit/ +│ │ └── AuditActionId.php +│ └── SupportRequests/ +│ ├── SupportRequestContextBuilder.php +│ ├── SupportRequestReferenceGenerator.php +│ ├── SupportRequestSubmissionService.php +│ └── ExternalSupportDeskHandoffService.php +├── config/ +│ └── support_desk.php +├── database/ +│ ├── factories/ +│ │ └── SupportRequestFactory.php +│ └── migrations/ +│ └── *_add_external_handoff_fields_to_support_requests_table.php +├── lang/ +│ ├── de/ +│ │ └── localization.php +│ └── en/ +│ └── localization.php +└── tests/ + ├── Feature/SupportRequests/ + │ ├── OperationRunSupportRequestExternalHandoffTest.php + │ ├── SupportRequestExternalHandoffAuditTest.php + │ ├── SupportRequestExternalHandoffAuthorizationTest.php + │ └── TenantSupportRequestExternalHandoffTest.php + └── Unit/Support/SupportRequests/ + ├── ExternalSupportDeskHandoffServiceTest.php + └── SupportRequestLatestHandoffSummaryTest.php +``` + +**Structure Decision**: Single Laravel application. The slice extends the existing support-request domain and two existing Filament pages only. One minimal application config contract in `config/support_desk.php` is in scope so target resolution is explicit, while workspace settings UI and a support settings domain remain out of scope. The constitution-mandated checklist in `checklists/requirements.md` stays part of the implementation handoff set. + +## Complexity Tracking + +| Violation / review item | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| Extend `support_requests` with four external-handoff columns | The operator must be able to revisit the current support context and still see the same external linkage or failure truth on the canonical support request | A separate `support_tickets` table would create a second lifecycle and a new surface the current slice does not need | +| Add one concrete provider-owned handoff service | One real external target must be called or normalized from both existing support-aware surfaces without page-local HTTP logic | A generic interface, registry, or multi-provider adapter catalog would be premature because the repo has exactly one current-release target case | + +## Proportionality Review + +- **Current operator problem**: the product can already create an internal support request with redacted context, but operators still have to create or paste an external desk ticket manually outside TenantPilot and then remember that linkage separately +- **Existing structure is insufficient because**: the current service ends at internal persistence and cannot carry durable external linkage or explicit failure truth back into the current support context +- **Narrowest correct implementation**: extend the existing `SupportRequest` row with minimal neutral linkage fields, route create or link decisions through the existing submission service, and render the latest linkage only inside the same two support-aware actions +- **Ownership cost created**: one additive migration, one concrete provider-owned service, a few audit IDs and audit-logger methods, modest action-form growth on two pages, and focused tests +- **Alternative intentionally rejected**: a new support-ticket model, support-request resource or detail page, target-management UI, provider registry, background retry path, or `OperationRun` delivery orchestration were all rejected as broader than current-release truth +- **Release truth**: current-release support follow-through and commercialization gap, not future ITSM platform preparation + +## Implementation Outline + +### 1. Support request persistence extension + +- Add the four external-handoff columns to `support_requests`. +- Default `external_handoff_mode` to `internal_only` so existing rows remain truthful without compatibility shims. +- Keep the internal `SR-...` reference canonical for every request. + +### 2. Submission service orchestration + +- Continue to authorize and validate through the current `SupportRequestSubmissionService` path. +- Persist the internal support request first and keep `WorkspaceAuditLogger::logSupportRequestCreated(...)` unchanged for that stage. +- Branch by handoff mode after the internal row exists: + - `internal_only`: return immediately with no external fields populated + - `link_existing_ticket`: validate and normalize the provided reference or URL locally, persist linkage, and audit `linked` + - `create_external_ticket`: call one concrete provider-owned handoff service outside the initial DB transaction with the five-second timeout budget, then perform the one allowed synchronous finalization write back to the same row and audit the outcome + +### 3. Latest-summary derivation + +- Add one shared read path for the latest handoff summary per primary context. +- Tenant summary queries the latest `support_requests` row for the current tenant where `primary_context_type = tenant`. +- Run summary queries the latest `support_requests` row for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run. +- The visible summary always includes the internal support reference it belongs to. + +### 4. Filament surface extension + +- Extend the existing `Request support` action on both pages with: + - mutation-scope guidance (`TenantPilot only` versus `TenantPilot + external support desk`) + - handoff mode choice + - conditional external reference and URL inputs for `link_existing_ticket` + - a read-only latest-handoff summary placeholder scoped to the current context +- Keep `Open support diagnostics` unchanged as the diagnostics-secondary affordance. +- Success notifications include the internal reference and, when present, the external reference. +- External create failure uses explicit partial-success or warning feedback: internal request created, external handoff failed. + +### 5. Audit and copy consistency + +- Add stable audit action IDs for: + - external ticket created + - external ticket linked + - external handoff failed +- Keep audit context bounded to workspace, tenant, internal support reference, primary context, handoff mode, and external ticket reference when present. +- Preserve neutral UI copy and do not surface provider product names as the primary operator vocabulary. + +## Implementation Phases + +1. **Foundation**: add the migration shape, model casts and constants, audit IDs, the concrete handoff service contract for one target, and the minimal `config/support_desk.php` contract. +2. **Submission flow**: refactor `SupportRequestSubmissionService` so internal creation commits first, then link or create outcome persists back to the same row. +3. **Surface wiring**: extend the tenant dashboard and run viewer forms with handoff mode, latest-summary placeholder, and outcome-sensitive notification copy. +4. **Hardening**: add latest-summary derivation, target-unavailable fallback to `internal_only`, authorization proof, and audit proof. + +## Guardrail Close-Out Expectations + +- Livewire v4 compatibility remains unchanged because the flow stays inside existing Filament v5 page actions. +- Laravel 12 provider registration facts remain unchanged: panel providers stay in `bootstrap/providers.php`. +- No globally searchable resource is added, so there is no new global-search contract to satisfy. +- No destructive action is introduced, so there is no new confirmation flow requirement. +- No new assets are required; `cd apps/platform && php artisan filament:assets` stays part of the general deployment path but does not change for this feature. diff --git a/specs/256-external-support-desk-handoff/quickstart.md b/specs/256-external-support-desk-handoff/quickstart.md new file mode 100644 index 00000000..d0ee5055 --- /dev/null +++ b/specs/256-external-support-desk-handoff/quickstart.md @@ -0,0 +1,48 @@ +# Quickstart — External Support Desk / PSA Handoff + +## Prereqs + +- Docker is running. +- Laravel Sail dependencies are installed. +- The support-request foundation from Spec 246 is already present in the workspace. +- One application-configured external support desk target is available through `apps/platform/config/support_desk.php`, or a fake target is available for implementation tests. + +## Run locally after implementation + +- Start containers: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d` +- Run targeted unit proof: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` +- Run targeted feature proof: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` +- Format after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/admin` as a workspace member with tenant entitlement and `support_requests.create` capability. +2. Open one tenant at `/admin/t/{tenant}` and trigger `Request support`. +3. Verify the action shows the existing context summary plus the new handoff mode controls. If no external target is configured in `config/support_desk.php`, verify the action clearly stays in `internal_only` mode. +4. Submit the tenant-context flow with `create_external_ticket` and verify the success notification includes the internal support reference plus the created external ticket reference. +5. Reopen the tenant-context action and verify the latest-handoff summary names the same internal support reference and external ticket reference. +6. Submit the tenant-context flow with `link_existing_ticket` and verify the stored summary shows the linked external reference without issuing a create call. +7. Force the external create path to fail, including the five-second timeout path, and verify the action returns explicit partial-success or warning feedback, the internal support request still exists, and the latest-handoff summary shows the persisted failure summary. +8. Open one canonical run detail page at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and repeat create, link, and failure checks there. +9. Verify a non-member or non-entitled actor receives `404`, while an in-scope member without `support_requests.create` sees the action disabled and receives `403` on execution. +10. Verify no new support-request resource, support queue, global-search result, or `OperationRun` side effect appears in this slice. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays inside native Filament page actions. +- No provider registration change is part of this slice; Laravel 12 panel providers remain registered in `bootstrap/providers.php`. +- No globally searchable resource is added, so there is no new global-search contract to satisfy. +- No destructive action is introduced, so `->requiresConfirmation()` is not newly involved here. +- No asset strategy changes are required. The general deploy step `cd apps/platform && php artisan filament:assets` remains unchanged. + +## Implementation Close-Out Expectations + +- The targeted unit and feature commands above pass. +- Manual smoke proves create, link, and explicit failure handling from both existing support-aware surfaces. +- Audit review shows `support_request.created`, `support_request.external_ticket_created`, `support_request.external_ticket_linked`, and `support_request.external_handoff_failed` events with the expected bounded metadata. +- Internal-only support-request submission still works when the external target is unavailable or intentionally bypassed. +- No new support product surface appears beyond the two existing actions. \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/research.md b/specs/256-external-support-desk-handoff/research.md new file mode 100644 index 00000000..74c18fc9 --- /dev/null +++ b/specs/256-external-support-desk-handoff/research.md @@ -0,0 +1,167 @@ +# Research — External Support Desk / PSA Handoff + +**Date**: 2026-04-29 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded decisions that make the Spec 256 plan implementation-ready without expanding into a generic helpdesk product. + +## Decision 1 — Extend `support_requests` instead of adding a second support-ticket truth + +**Decision**: Keep `App\Models\SupportRequest` as the only persisted truth for this slice and add the external linkage fields directly to `support_requests`. + +**Rationale**: +- The repo already has one canonical support-request model, migration, factory, and submission service. +- The operator workflow needs one durable record that still carries the internal `SR-...` reference after create, link, or failure. +- Constitution `PERSIST-001` and `PROP-001` reject a second lifecycle unless it solves a distinct product problem. Spec 256 does not need one. + +**Evidence**: +- Existing model: `apps/platform/app/Models/SupportRequest.php` +- Existing persistence: `apps/platform/database/migrations/2026_04_27_095518_create_support_requests_table.php` +- Existing write path: `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` +- Candidate scope: `docs/product/spec-candidates.md` + +**Alternatives considered**: +- Add a new `SupportTicket` or `SupportRequestLink` model. + - Rejected: creates a second truth and encourages a support register or detail page the spec explicitly forbids. +- Keep external linkage entirely derived from audit logs. + - Rejected: the current support context must show the latest linkage on revisit, which audit-only storage cannot do safely or cheaply. + +## Decision 2 — Persist a bounded failure summary on the same row; keep detailed provider failure out of product truth + +**Decision**: Store `external_handoff_failure_summary` on `support_requests` and keep detailed provider payloads or raw errors out of persisted support-request truth. + +**Rationale**: +- The spec requires explicit, revisitable failure handling in the same support context. +- A purely audited failure would satisfy compliance but fail the operator need to reopen the action and see what happened. +- A bounded human-readable summary is enough for revisit. Provider-specific payloads remain provider-owned and redaction-sensitive. + +**Evidence**: +- Existing audit path is already separate from product truth: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` +- Current support-request row has no external linkage or failure fields, so the revisit contract is impossible without row-level extension. + +**Alternatives considered**: +- Audit failure only. + - Rejected: failure becomes invisible in the current support context. +- Persist raw provider response JSON. + - Rejected: violates the spec’s minimal neutral truth and increases leakage risk. + +## Decision 3 — Keep the flow synchronous, preserve internal durability, and document the one bounded finalization write + +**Decision**: Preserve the existing synchronous submit flow, move any external create call outside the current internal-request creation transaction, enforce a five-second outbound timeout, and explicitly allow one bounded post-create finalization write on the same `SupportRequest` row. + +**Rationale**: +- The current service wraps internal create plus audit in a transaction. +- Spec 256 explicitly requires the internal support request to survive external create failure. +- Spec 246 declared the row immutable after creation, so Spec 256 must make its one bounded finalization exception explicit instead of mutating the row silently. +- Holding a database transaction open across remote HTTP is unnecessary and increases latency and failure risk. +- A hard timeout budget is needed so the operator-visible submit path stays bounded and timeout behavior is testable. +- The repo truth does not require `OperationRun`, queueing, or retry scheduling for this slice. + +**Evidence**: +- Existing transaction structure in `SupportRequestSubmissionService` +- Existing synchronous page actions on `TenantDashboard` and `TenantlessOperationRunViewer` +- The spec’s explicit non-goal for queues, retries, and `OperationRun` +- Spec 246 FR-246-011 immutability contract + +**Alternatives considered**: +- Perform external HTTP inside the current DB transaction. + - Rejected: risks long transactions and makes internal request durability harder to guarantee. +- Introduce queue work or `OperationRun`. + - Rejected: broader than current-release truth and not required for one synchronous target. +- Keep Spec 246 immutability unchanged and infer final handoff state only from audit. + - Rejected: the current support context must show revisitable success or failure on the canonical `SupportRequest`, so the one bounded finalization write has to be explicit. + +## Decision 4 — Keep external linkage visibility inside the existing support request actions only + +**Decision**: Show the latest linkage summary inside the existing `Request support` slide-overs and in submit feedback. Do not add a new support page, dashboard card, or run-detail history section. + +**Rationale**: +- The spec says visibility must stay attached to the existing tenant and run support contexts. +- The acceptance criteria require reopening the action and seeing the latest linkage summary for that same context. +- A broader always-visible history surface would deepen support-product scope and duplicate truth. + +**Evidence**: +- Existing support-aware surfaces: `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- No existing `SupportRequest` resource, list, or detail page exists in the repo today. + +**Alternatives considered**: +- Add a support-request resource or detail page. + - Rejected: explicitly out of scope. +- Add a new page-level widget or card for support linkage. + - Rejected: broader than the acceptance requirement and would create duplicate visible truth. + +## Decision 5 — Add one minimal application config contract; do not hide target resolution behind an undefined prerequisite + +**Decision**: Bring one minimal application config contract into scope through `apps/platform/config/support_desk.php` and environment-backed values for the single supported target. Do not add workspace settings UI, a support settings domain, or provider-connection product work. + +**Rationale**: +- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation work. +- The product contract only needs one target for v1, so an application config contract is the narrowest explicit source of truth. +- Pulling workspace administration into Spec 256 would still expand scope from handoff to setup and administration. + +**Evidence**: +- Existing repo truth has no support-target config seam yet, so a new app config file is the explicit minimal source of truth for one target. + +**Alternatives considered**: +- Add a new `support` settings domain and UI in the same spec. + - Rejected: becomes a second feature slice. +- Reuse `ProviderConnection` as the support target model. + - Rejected: not justified by current repo truth for one external desk handoff target. +- Leave target resolution as an undefined prerequisite. + - Rejected: the tasks and plan already depend on a concrete resolution seam, so the config contract must be explicit inside the package. + +## Decision 6 — Use one concrete provider-owned handoff service, not a registry or interface framework + +**Decision**: Add one concrete provider-owned handoff service under the support-request path for the single real external target. + +**Rationale**: +- Both existing support surfaces need the same create-or-normalize behavior. +- Constitution `ABSTR-001` rejects a provider registry or interface framework before two real targets exist. +- Page-local HTTP logic would duplicate failure handling, normalization, and audit shape. + +**Evidence**: +- One configured target only in the spec and roadmap candidate +- Existing shared write path already centralizes support-request submission across both surfaces + +**Alternatives considered**: +- Add a provider interface plus registry. + - Rejected: future-proofing without current-release variance. +- Duplicate HTTP logic inside both Filament pages. + - Rejected: immediate drift risk and weaker audit consistency. + +## Decision 7 — Keep queries context-scoped and avoid new search or indexing semantics + +**Decision**: Derive the latest visible linkage from the latest support request for the same primary context, using the existing context indexes. Do not add cross-scope lookup or search by external ticket reference. + +**Rationale**: +- Tenant summary and run summary have different scope rules in the spec. +- Existing indexes already support latest-by-tenant and latest-by-run queries. +- Cross-scope lookup by external reference is explicitly out of scope and would create a new leakage risk. + +**Evidence**: +- Existing indexes on `support_requests(tenant_id, created_at)` and `support_requests(operation_run_id, created_at)` +- Context scoping in `SupportRequest::PRIMARY_CONTEXT_TENANT` and `SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN` + +**Alternatives considered**: +- Add an index and lookup flow for external ticket reference. + - Rejected: no current surface needs it, and it would conflict with the no-cross-scope-shortcuts rule. + +## Decision 8 — Proof stays in Unit + Feature lanes with manual smoke only + +**Decision**: Keep the proving strategy in focused Pest unit and feature suites, then use a narrow manual smoke path after implementation. + +**Rationale**: +- Business truth is server-side: branching, persistence, audit, and authorization. +- Existing support-request tests already cover the same two Filament entry surfaces. +- Browser coverage would mostly duplicate the existing action-form semantics. + +**Evidence**: +- Existing test family: + - `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php` + - `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php` + - `apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php` + - `apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php` + +**Alternatives considered**: +- Add browser tests in the first slice. + - Rejected: not required to prove the current business truth. \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/spec.md b/specs/256-external-support-desk-handoff/spec.md new file mode 100644 index 00000000..11fc59ea --- /dev/null +++ b/specs/256-external-support-desk-handoff/spec.md @@ -0,0 +1,331 @@ +# Feature Specification: External Support Desk / PSA Handoff + +**Feature Branch**: `256-external-support-desk-handoff` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "Prepare the next open candidate External Support Desk / PSA Handoff as the narrowest repo-grounded slice that extends the already-implemented in-app support request flow with one-way external ticket create or link behavior, stores the resulting external reference on the existing support-request truth, and keeps visibility on the existing tenant and operation-run support contexts only." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already captures support requests with internal `SR-...` references, redacted context, and audit truth, but support follow-through still breaks at the product boundary because operators must create or paste external service-desk tickets manually outside the current workflow. +- **Today's failure**: A tenant or run-scoped support request can be submitted from the product, yet the product cannot tell the operator whether an external ticket was created, linked, or failed. That creates manual duplicate work, weakens audit continuity, and leaves no durable external-ticket linkage in the current support context. +- **User-visible improvement**: The existing `Request support` action can create a new external desk ticket or link an already-created ticket through one configured external desk target, then show the resulting external reference or explicit failure in the same tenant or run support context. +- **Smallest enterprise-capable version**: Extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` so one configured external support desk target can be used during the existing tenant-dashboard and operation-run support flows, persist the resulting external ticket reference or last handoff failure on the same support request, audit create or link outcomes, and surface the latest handoff summary only on those same support contexts. +- **Explicit non-goals**: No new support-request creation flow, no support-request resource/list/detail page, no support inbox or queue product, no generic helpdesk framework, no multi-provider adapter registry, no bidirectional sync, no external ticket status polling, no SLA engine, no retry scheduler, no AI support automation, and no cross-workspace or cross-tenant handoff shortcuts. +- **Permanent complexity imported**: One bounded provider-owned handoff adapter for a single configured external target, a small external-handoff mode family on the existing support-request truth, a nullable persisted external ticket reference and URL on `support_requests`, a bounded persisted handoff failure summary, targeted audit action IDs, and focused unit plus feature coverage. +- **Why now**: `docs/product/spec-candidates.md` and `docs/product/roadmap.md` both confirm that support-request creation is already repo-real and that the remaining commercialization gap is external handoff plus visible ticket linkage, not another internal support intake feature. +- **Why not local**: Page-local ticket creation or a manual copy field on each surface would duplicate logic that already lives in `SupportRequestSubmissionService`, would drift audit and failure behavior between tenant and run contexts, and would still not create one durable support-request-to-external-ticket truth. +- **Approval class**: Workflow Compression +- **Red flags triggered**: New provider seam, new persisted fields on an existing truth model, and multi-surface action changes. Defense: the slice extends one existing model and one existing submission path, stays on two already-support-aware surfaces, and explicitly forbids a generic helpdesk or queue framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant, canonical-view +- **Primary Routes**: + - existing tenant dashboard at `/admin/t/{tenant}` via `App\Filament\Pages\TenantDashboard` + - existing canonical operation detail at `/admin/operations/{run}` via `App\Filament\Pages\Operations\TenantlessOperationRunViewer` + - no new dedicated support desk or support-request route in v1 +- **Data Ownership**: + - `support_requests` remains the canonical tenant-owned truth and continues to carry required `workspace_id` and `tenant_id` + - external handoff truth extends that same record only: external ticket reference, external ticket URL, handoff mode, and last handoff failure are stored on the existing support request rather than in a new ticket-link model or table + - one configured external support desk target is treated as application-configured integration truth and is referenced during handoff, but it is not mirrored into tenant-owned support-request records beyond the neutral external linkage fields needed for operator continuity and auditability +- **RBAC**: + - workspace membership and tenant entitlement remain the first isolation boundaries + - the existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability continues to gate support-request submission and any visible create-or-link external handoff controls + - non-members or actors not entitled to the workspace or tenant scope receive `404` + - members inside scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403` + - run-context handoff and any latest-handoff summary on the run page resolve the run's tenant first and must not reveal linkage state for a tenant the actor cannot access + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: `N/A` - the feature does not add a canonical collection page; the operation-run surface stays bound to the currently opened run only. +- **Explicit entitlement checks preventing cross-tenant leakage**: Any lookup used to show a latest external handoff summary on the operation-run support context must resolve through the current run's entitled tenant scope and the current workspace. Known internal support references or external ticket references must not bypass that scope check. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: header actions, contextual support capture, success and failure notifications, support-context summaries, audit events, and external-link navigation +- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\SupportRequests\SupportRequestSubmissionService`, `App\Support\SupportRequests\SupportRequestContextBuilder`, `App\Support\SupportRequests\SupportRequestReferenceGenerator`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Audit\AuditActionId`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and existing `UiEnforcement` capability gating on both support actions +- **Existing pattern(s) to extend**: the current `Request support` slide-over actions, current support-request success feedback, current support-diagnostics context summary, current audit logging path, and current tenant/run support authorization boundaries +- **Shared contract / presenter / builder / renderer to reuse**: `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-diagnostics bundle as the canonical redacted context source +- **Why the existing shared path is sufficient or insufficient**: The current shared path already assembles support-safe context, issues the internal `SR-...` reference, and writes audit truth consistently from both existing entry surfaces. It is insufficient only because it stops at internal persistence and cannot persist or surface external desk follow-through. +- **Allowed deviation and why**: One provider-owned external handoff adapter or service is allowed inside the support-request path because one configured external desk target must be called or normalized from both surfaces. No second page-local handoff client, no generic helpdesk registry, and no parallel support-summary vocabulary are allowed. +- **Consistency impact**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and handoff failure wording must have the same meaning on tenant and run surfaces, in success or failure notifications, and in audit summaries. +- **Review focus**: Reviewers must block any page-local external desk payload builder, any second support-ticket persistence model, and any new support status language that duplicates the existing support-request truth instead of extending it. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: The operation-run page continues to use the current run only as support context. External desk handoff must not create, resume, or otherwise mutate an `OperationRun`. +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## 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 +- **Boundary classification**: provider-owned +- **Seams affected**: outbound create request payloads, external ticket URL and reference normalization, external-target credential resolution, provider-specific response parsing, and provider-specific failure normalization +- **Neutral platform terms preserved or introduced**: support request, support reference, external ticket, external ticket reference, external ticket URL, external handoff mode, external handoff failure, and latest handoff summary +- **Provider-specific semantics retained and why**: Authentication, request payload shape, URL templates, provider-specific ticket IDs, and provider-specific validation rules remain inside the one configured external desk adapter because only one concrete target exists in the current release slice. +- **Why this does not deepen provider coupling accidentally**: The `SupportRequest` record stores only neutral linkage truth needed for operator continuity: the external reference, optional URL, selected handoff mode, and explicit last failure summary. It does not store provider-specific fields such as assignee, queue, SLA, raw payloads, or external status history. +- **Follow-up path**: `follow-up-spec` only if a second real external desk target exists or the first target proves that a provider-neutral shared boundary is genuinely needed. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Tenant dashboard `Request support` action | yes | Native Filament action + shared support primitives | header actions, support capture, support diagnostics, success or failure notifications | page, action form, bounded latest-handoff summary | yes | Existing tenant-dashboard action-surface exception remains bounded; the feature extends the current slide-over instead of adding a support page | +| Operation run `Request support` action | yes | Native Filament action + shared support primitives | grouped detail actions, support capture, monitoring-state support context, success or failure notifications | detail page, action form, bounded latest-handoff summary | no | Extends an already support-aware run-detail action instead of adding a second run-support surface | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Tenant dashboard `Request support` action | Secondary Context Surface | The operator decides that the current tenant issue needs external escalation or explicit desk linkage | current support summary, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper support diagnostics remain on the neighboring `Open support diagnostics` action | Secondary because the operator is still primarily troubleshooting the tenant, not working in a support-desk inbox | Follows current tenant troubleshooting and support-escalation flow | Removes manual copy-paste and out-of-band ticket bookkeeping from the tenant troubleshooting path | +| Operation run `Request support` action | Secondary Context Surface | The operator decides that the current run already contains enough context to hand off or link to an external desk | run identity, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper run diagnostics remain on the existing support-diagnostics action and run detail sections | Secondary because the operator is still primarily inspecting one run | Follows current run drill-in workflow | Removes the need to recreate the same run context in an external desk after the operator has already drilled into it | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Tenant dashboard `Request support` action | operator-MSP, support-platform | support summary, selected mutation scope, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted support diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; any provider-specific fields stay inside the adapter and never appear as default-visible operator content | the slide-over states the current handoff truth once and links it to a specific internal support reference instead of duplicating support-request history blocks | +| Operation run `Request support` action | operator-MSP, support-platform | run identity, mutation scope note, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted run diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; run detail stays the primary evidence surface | the slide-over shows only the latest bounded linkage summary for this run context instead of becoming a support-request register | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Tenant dashboard `Request support` action | Dashboard / Overview / Actions | Tenant support escalation entry point | submit the support request with one chosen external handoff mode | explicit header action opens the existing slide-over | forbidden | `Open support diagnostics` remains the neighboring secondary action; any external link stays inside the same support context summary | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | active workspace, active tenant, and current support context summary | Support request / External ticket | whether the next submit stays internal-only, creates an external ticket, or links an existing ticket | dashboard_exception - existing tenant dashboard action-surface exception remains bounded and justified by the dashboard's role as the tenant troubleshooting hub | +| Operation run `Request support` action | Record / Detail / Actions | Run-centered support escalation entry point | submit the support request with one chosen external handoff mode | explicit detail action in the existing grouped support actions | forbidden | `Open support diagnostics` remains grouped beside `Request support`; any external link stays inside the same support slide-over | none | `/admin/operations` | `/admin/operations/{run}` | workspace context, entitled tenant context, and current operation identifier | Support request / External ticket | whether the current run context already has an external ticket linkage or a visible last handoff failure | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant dashboard `Request support` action | Workspace manager or support-capable tenant operator | Decide whether this tenant issue should stay internal, create a new external ticket, or link an existing ticket | Dashboard action + contextual slide-over | How do I hand this tenant issue off without losing the current support-request truth? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and context attachment summary | full support diagnostics remain in the neighboring diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none | +| Operation run `Request support` action | Workspace manager or support-capable operator | Decide whether this run issue should stay internal, create a new external ticket, or link an existing ticket | Detail action + contextual slide-over | How do I hand this run issue off without recreating the case outside the product? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and run-context attachment summary | full run diagnostics remain in the run detail and diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no - the feature extends the existing `SupportRequest` truth rather than introducing a second ticket-link model or support queue truth +- **New persisted entity/table/artifact?**: no new table; yes, the existing `support_requests` truth gains bounded external handoff fields needed for operator continuity and auditability +- **New abstraction?**: yes - one provider-owned external handoff adapter or service for the single configured target +- **New enum/state/reason family?**: yes - one small external handoff mode family (`create_external_ticket`, `link_existing_ticket`, `internal_only`) with direct operator and mutation consequences +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: support requests already exist, but the product still cannot show whether an external desk ticket exists or was attempted from the same support context +- **Existing structure is insufficient because**: the current submission service and UI end at internal persistence and cannot safely call or normalize an external desk, store the resulting reference, or keep failure truth visible for the current context +- **Narrowest correct implementation**: extend the current `SupportRequest` submission path and current tenant or run support actions only, add one provider-owned handoff adapter for one configured target, and store only the minimal linkage truth on the same support request +- **Ownership cost**: extra `support_requests` columns, one provider-owned handoff adapter or service, stable audit IDs for external handoff outcomes, slightly richer action forms, and focused unit plus feature coverage +- **Alternative intentionally rejected**: a new `SupportTicket` model, a support-request detail resource, or a generic multi-provider helpdesk framework was rejected because the repo currently has only one real external desk use case and already has the support-request truth needed for v1 +- **Release truth**: current-release support follow-through and commercialization gap, not future multi-provider preparation + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage can prove the external handoff adapter normalization rules, handoff mode branching, and latest-handoff summary derivation cheaply. Focused Filament feature coverage can prove tenant and run action behavior, `404` versus `403` boundaries, persisted linkage truth, explicit failure handling, and audit events without needing browser-only coverage. +- **New or expanded test families**: one bounded `Unit/Support/SupportRequests/ExternalSupportDesk*` family and one bounded `Feature/SupportRequests/*ExternalHandoff*` family +- **Fixture / helper cost impact**: moderate. Reuse existing workspace, tenant, operation run, support request, and authorization fixtures. Add only the narrow target-configuration and adapter-fake setup needed for create or link success and failure paths. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament, monitoring-state-page +- **Standard-native relief or required special coverage**: standard Filament action coverage is sufficient for the tenant dashboard action. The run-context action must also preserve the existing canonical monitoring-state-page authorization and context rules. +- **Reviewer handoff**: Reviewers must confirm that no support-request resource or queue page appears, that create failures keep the internal support request, that latest external linkage stays scoped to the current entitled context, and that no provider-specific payloads leak into the persisted support-request truth. +- **Budget / baseline / trend impact**: low-to-moderate increase in narrow unit plus feature coverage only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` + +## External Handoff Contract + +The first slice extends the existing support-request truth instead of creating a second support-ticket product model. + +| Handoff mode | Operator intent | External effect | Stored support-request truth | Default-visible result | +|---|---|---|---|---| +| `create_external_ticket` | Create a new external desk ticket from the current support context | One outbound create call through the configured external desk adapter | external ticket reference, optional external ticket URL, chosen mode, and cleared failure summary on success | success feedback shows the internal support reference plus the created external ticket reference | +| `link_existing_ticket` | Record an external ticket that already exists outside the product | No outbound ticket-create call; the bounded adapter may normalize the provided reference or URL for the configured target | operator-supplied external ticket reference, optional external ticket URL, chosen mode, and no provider payload mirror | success feedback shows the internal support reference plus the linked external ticket reference | +| `internal_only` | Keep the request internal-only when the operator intentionally defers external follow-through or when no target is configured for the application | No outbound call | no external ticket reference, chosen mode, and no external failure summary | the support context clearly states that no external ticket is linked yet | + +Additional rules for v1: + +- The internal `SR-...` support reference remains the canonical TenantPilot support-request identifier even when an external ticket exists. +- V1 does not store external assignee, SLA, comments, status history, or raw provider payloads. +- V1 does not auto-retry failed create calls. If a retry or relink path becomes necessary later, it requires a follow-up spec. + +## Scope Boundaries + +### In Scope + +- extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` rather than introducing a new support domain model +- offer one bounded external handoff mode selector inside the current tenant and run `Request support` actions +- allow one support request to either create an external ticket, link an existing external ticket, or stay internal-only +- call exactly one application-configured external support desk or PSA target when the operator chooses `create_external_ticket` +- store the resulting external ticket reference and optional URL on the same support request record +- store a bounded last handoff failure summary on the same support request when external create fails after the internal request exists +- write explicit audit events for external ticket created, external ticket linked, and external handoff failed +- show the latest external handoff summary for the current tenant or run support context without adding a broad support-product surface +- keep current redacted support context attachment behavior from `SupportRequestContextBuilder` + +### Non-Goals + +- re-specifying or replacing support-request creation from Spec 246 +- creating a `SupportRequestResource`, support-request register, or support-request detail page +- a generic ticketing or helpdesk framework with provider discovery or multiple adapters +- bidirectional sync, external ticket status refresh, webhook ingestion, or comment sync +- SLA, priority routing, assignment, support inbox, triage queue, or customer portal work +- AI-generated support summaries or automation +- background jobs or scheduled retries for external desk delivery +- cross-workspace or cross-tenant linking shortcuts based on a known support reference or ticket reference alone + +## Assumptions + +- Exactly one application-configured external support desk target can be resolved through a minimal config contract added in this slice. This spec does not introduce workspace settings UI, per-workspace target management, or a broader support-desk configuration product surface. +- The existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability is sufficient for v1. No new role family or support-only secondary capability is required. +- The current redacted support context envelope produced by `SupportRequestContextBuilder` is already the canonical payload basis for external handoff. This feature does not redefine the support context contract. +- Internal support-request creation remains allowed even when the external target is unavailable or an external create attempt fails, because the product must preserve the internal support truth and auditability. + +## Risks + +- A synchronous external create call can slow the current support action if the provider-owned handoff service does not enforce the v1 five-second timeout budget and normalize timeout failures into the same bounded failure-summary path. +- If a tenant or run has multiple support requests, the latest-handoff summary can mislead operators unless it also names the internal support reference it belongs to. +- Provider-specific response fields can leak into the support-request truth if the adapter boundary is not enforced strictly. +- The manual `link_existing_ticket` path could grow into a broader external-ticket management surface if it is allowed outside support-request submission. That growth is out of scope for v1. + +## Follow-up Candidates + +- a second external support desk or PSA target only after a concrete second target exists and the first target proves real operator value +- a bounded retry or relink flow from the same support contexts only if repeated external create failures become a proven operator pain point +- a read-only support-request register only if current tenant or run context visibility is no longer sufficient +- bidirectional sync or external ticket status refresh only if operators demonstrate a real need beyond stored reference continuity + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Create a new external ticket from the existing support flow (Priority: P1) + +As a support-capable operator, I want the existing support-request action to create an external desk ticket from the current tenant or run context so I do not have to recreate the same case manually outside TenantPilot. + +**Why this priority**: This is the direct commercialization gap named by the roadmap and candidate. Without outbound create, the product still stops at an internal support request only. + +**Independent Test**: Submit a support request from the tenant dashboard and from the operation-run viewer with `create_external_ticket`, fake the configured external desk target, and verify that the support request keeps the internal `SR-...` reference while also storing the returned external ticket reference and URL. + +**Acceptance Scenarios**: + +1. **Given** an entitled operator opens the tenant dashboard and the application has one configured external desk target, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system creates the internal support request, creates one external ticket through the bounded adapter, stores the resulting external ticket reference on that same support request, and returns both references in success feedback. +2. **Given** an entitled operator opens an operation run that resolves to an entitled tenant, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system stores the internal support request with the run as primary context and persists the external ticket linkage on that same request. +3. **Given** the current context already has an earlier external handoff summary, **When** the operator opens the current `Request support` action again, **Then** the action shows the latest external linkage summary for that same context without turning the surface into a support-request history page. + +--- + +### User Story 2 - Link an already-existing external ticket during support submission (Priority: P1) + +As a support-capable operator who already opened a desk ticket outside TenantPilot, I want to link that ticket during support-request submission so the product records the same external reference without creating a duplicate external case. + +**Why this priority**: The candidate explicitly requires create or link behavior, and linking an already-created external ticket is the smallest way to avoid duplicates without inventing a broader support-ticket management surface. + +**Independent Test**: Submit the existing tenant or run `Request support` action with `link_existing_ticket`, provide a ticket reference and optional URL, and verify that the support request stores that linkage truth without issuing an external create call. + +**Acceptance Scenarios**: + +1. **Given** an entitled operator already has an external ticket reference, **When** the operator submits the existing tenant-context support action with `link_existing_ticket`, **Then** the system persists the provided external reference on the same support request and records an explicit audit event that the ticket was linked rather than created. +2. **Given** an entitled operator is on the operation-run support context, **When** the operator submits the action with `link_existing_ticket`, **Then** the system links the external reference to the run-scoped support request without creating a new external desk ticket. +3. **Given** the operator leaves the ticket reference blank or otherwise invalid for the bounded target format, **When** the action is submitted with `link_existing_ticket`, **Then** the system rejects the linkage input and does not create misleading external-ticket truth. + +--- + +### User Story 3 - Keep failures explicit, scoped, and auditable (Priority: P2) + +As a support-capable operator, I want external handoff failures to be explicit without losing the internal support request so I can continue follow-through safely and without guessing what happened. + +**Why this priority**: The value of external handoff depends on failure honesty. Silent loss of the desk ticket or silent loss of the internal request would be worse than the current manual workflow. + +**Independent Test**: Force the external adapter to fail during `create_external_ticket`, then verify that the internal support request remains persisted, the current support context shows the latest failure summary for that same support reference, and audit truth records the failed handoff. + +**Acceptance Scenarios**: + +1. **Given** the internal support request is created successfully but the external create call fails, **When** the action completes, **Then** the internal support request remains persisted, the operator receives explicit partial-success feedback with the internal support reference plus the handoff failure, and the failed handoff is audited. +2. **Given** a user is not entitled to the current workspace or tenant scope, **When** they attempt to access tenant or run external handoff state or submit the support action, **Then** the system returns `404` and reveals neither the internal support reference nor any external ticket reference. +3. **Given** a user is entitled to the tenant but lacks `Capabilities::SUPPORT_REQUESTS_CREATE`, **When** they attempt the same action, **Then** the system returns `403` and does not create, link, or reveal external handoff truth. + +### Edge Cases + +- The application may not have an external desk target configured. In that case the existing support-request flow must remain available in `internal_only` mode with an explicit note that no external target is configured. +- An external create call may fail after the internal support request is already committed. The request must remain the canonical support truth and must keep a bounded failure summary rather than disappearing or rolling back silently. +- A tenant or run can have multiple support requests over time. The visible handoff summary on the current support context must clearly identify which internal support reference the shown external ticket reference belongs to. +- An operator may know an external ticket reference but not a URL. The product may store the reference alone in v1 and must not invent a URL it cannot prove. +- The operation-run viewer can only surface latest handoff state when the run resolves to an entitled tenant. Runs without an entitled tenant must continue to resolve as `404` without leaking any linkage hint. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds a synchronous outbound create call to one configured external support desk target as part of an existing support-request mutation. It does not create a new `OperationRun`, queue, or scheduler. Successful internal request creation, external ticket creation, external ticket linking, and external handoff failure MUST all be auditable. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature extends the existing `SupportRequest` truth instead of adding a second support-ticket model or queue. The only new semantic family is one bounded handoff mode family because operator choice and resulting mutation behavior differ materially between create, link, and internal-only paths. + +**Constitution alignment (XCUT-001):** Existing `Request support` actions, support-diagnostics context, `SupportRequestSubmissionService`, and `WorkspaceAuditLogger` must be reused. Any new external handoff behavior must plug into that shared path instead of creating separate tenant and run implementations. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** External handoff truth must stay secondary to the current tenant or run troubleshooting workflow. The current support contexts should show only the bounded latest linkage summary or failure, while diagnostics remain separately opened and raw provider details remain hidden. + +**Constitution alignment (PROV-001):** External desk payloads, authentication, and provider-specific identifiers remain provider-owned. The shared product truth remains the existing `SupportRequest` plus neutral external linkage fields only. + +**Constitution alignment (TEST-GOV-001):** Proof stays in unit plus feature lanes only. Browser and heavy-governance coverage are out of scope for the first slice. + +**Constitution alignment (RBAC-UX):** The affected authorization plane remains the tenant-admin `/admin` plane. Non-members and non-entitled users receive `404`. Entitled users lacking `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403`. No raw capability strings or role-string checks may appear in feature code. + +**Constitution alignment (UI-FIL-001):** The feature must continue to use native Filament actions and action forms on the current pages. No custom standalone support desk page or local replacement shell is allowed. + +**Constitution alignment (UI-NAMING-001):** Primary operator-facing copy must preserve `Request support`, `Support reference`, and `External ticket`. Provider-specific product names, payload terminology, or API vocabulary must not replace those primary labels. + +### Functional Requirements + +- **FR-256-001 Existing surfaces only**: The system MUST extend only the existing tenant dashboard and canonical operation-run `Request support` actions for v1. It MUST NOT introduce a new support-request resource, support-request detail view, or support queue page. +- **FR-256-002 Bounded handoff mode choice**: When the application has a configured external desk target, the existing `Request support` action MUST let the operator choose exactly one of `create_external_ticket`, `link_existing_ticket`, or `internal_only`. When no target is configured, the action MUST remain available in `internal_only` mode and MUST explain that no external desk target is configured. +- **FR-256-003 Internal request remains canonical**: Every path in this feature MUST create or preserve the existing internal `SupportRequest` truth first. The internal `SR-...` reference remains the canonical support-request identifier even when an external ticket is created or linked. +- **FR-256-003A Bounded finalization exception to Spec 246 immutability**: Spec 256 explicitly narrows Spec 246 FR-246-011 in one bounded way: after internal request creation, the same `SupportRequest` row MAY receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No broader edit, reopen, merge, or status workflow is introduced. +- **FR-256-004 External create path**: When the operator selects `create_external_ticket`, the system MUST call exactly one application-configured external support desk target through one bounded provider-owned adapter, apply a maximum five-second outbound timeout, store the returned external ticket reference on the same support request, and store the external ticket URL when the target returns one. +- **FR-256-005 External link path**: When the operator selects `link_existing_ticket`, the system MUST store the provided external ticket reference on the same support request and MUST NOT issue an external ticket-create call for that request. +- **FR-256-006 Persisted linkage truth**: The existing `support_requests` truth MUST be extended with only the neutral external linkage fields needed for operator continuity: external ticket reference, optional external ticket URL, selected handoff mode, and bounded last handoff failure summary. +- **FR-256-007 No mirrored external lifecycle**: V1 MUST NOT persist or display external assignee, SLA, queue, comment stream, status history, or raw provider payloads. +- **FR-256-008 Failure honesty**: If the external create path fails after the internal support request exists, the system MUST keep the internal request, persist a bounded last handoff failure summary on that same request, and show explicit feedback that the internal request succeeded but the external handoff failed. +- **FR-256-009 Context-safe visibility**: The current tenant and run support contexts MUST show the latest external handoff summary for that same primary context, including the internal support reference it belongs to, without becoming a broad support-request history surface. +- **FR-256-010 Audit coverage**: The system MUST write stable audit entries for support request created, external ticket created, external ticket linked, and external handoff failed, with workspace and tenant context plus the internal support reference and the external ticket reference when present. +- **FR-256-011 Authorization boundaries**: Non-members and non-entitled actors MUST receive `404`. Members in scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` MUST receive `403`. Latest-handoff visibility, create, and link behavior MUST all enforce the same boundary. +- **FR-256-012 Provider boundary**: Provider-specific authentication, request payload shape, response parsing, and URL normalization MUST remain inside one provider-owned adapter or service. Shared platform code MUST work only with the neutral external linkage truth stored on `SupportRequest`. +- **FR-256-013 No background expansion**: V1 MUST NOT add background jobs, retry scheduling, webhook ingestion, or `OperationRun` usage for external desk delivery. +- **FR-256-014 No cross-scope shortcuts**: A known internal support reference or external ticket reference MUST NOT be sufficient to reveal or mutate linkage truth outside the current entitled workspace and tenant scope. +- **FR-256-015 Mutation-scope clarity**: The existing support actions MUST make it clear whether the current submission writes to `TenantPilot only` or to `TenantPilot + external support desk`, based on the selected handoff mode. +- **FR-256-016 Timeout normalization**: When `create_external_ticket` exceeds the five-second outbound timeout budget or times out for any other target-level reason, the system MUST keep the internal support request, persist a bounded timeout-oriented failure summary on the same row, and route the outcome through the same explicit feedback and audit path as other external create failures. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant dashboard support context | `App\Filament\Pages\TenantDashboard` | `Request support`, `Open support diagnostics` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | Existing dashboard action-surface exemption remains. The feature only extends the current `Request support` action with handoff mode choice and latest linkage summary. | +| Operation-run support context | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | grouped `Open support diagnostics`, `Request support` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | No new run action group or support page. The feature only extends the existing support action with handoff mode choice and latest linkage summary. | + +## Key Entities *(include if feature involves data)* + +- **Support Request**: Existing tenant-owned support truth with internal reference, primary context, redacted context envelope, severity, and the new bounded external linkage fields needed for external handoff continuity. +- **External Support Desk Target**: The single application-configured external desk or PSA destination used for v1 handoff. It owns provider-specific authentication and payload semantics. +- **External Ticket Linkage**: The bounded support-request extension that records whether the current request stayed internal-only, created an external ticket, or linked an existing one, together with the neutral external ticket reference, optional URL, and last failure summary. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: From the existing tenant dashboard or operation-run support context, an authorized operator can complete support-request submission with external create or link behavior in one flow without leaving the current page to recreate the case manually. +- **SC-002**: 100% of successful external create or link submissions persist an external ticket reference on the same support request and make that reference visible again from the same entitled support context on revisit. +- **SC-003**: 100% of external create failures leave the internal support request intact, produce explicit operator-visible failure feedback, and write an audit entry for the failed handoff. +- **SC-004**: Authorization tests prove that operators never see or mutate external ticket linkage for a workspace or tenant they are not entitled to, even when they know an internal support reference or external ticket reference. diff --git a/specs/256-external-support-desk-handoff/tasks.md b/specs/256-external-support-desk-handoff/tasks.md new file mode 100644 index 00000000..6a4cddea --- /dev/null +++ b/specs/256-external-support-desk-handoff/tasks.md @@ -0,0 +1,192 @@ +--- + +description: "Task list for External Support Desk / PSA Handoff" + +--- + +# Tasks: External Support Desk / PSA Handoff + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md` + +**Support truth**: Spec 246 and the existing repo code remain authoritative, except for one bounded Spec 256 finalization exception: after internal request creation, the same `SupportRequest` row may receive exactly one synchronous write limited to the external handoff fields. Extend `apps/platform/app/Models/SupportRequest.php` and the current support-request submission path only; do not add a second support-ticket entity, support queue, support register, or support-request resource. +**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in focused unit plus feature lanes only, then run the narrow manual smoke path from `quickstart.md`. +**Operations**: This slice must stay synchronous inside the existing support-request path. Do not create, queue, resume, or complete an `OperationRun`. +**RBAC**: Workspace membership and tenant entitlement remain `404` boundaries; in-scope members missing `Capabilities::SUPPORT_REQUESTS_CREATE` remain `403`; latest-handoff visibility must follow the same boundary. +**Provider boundary**: One configured external desk target only. No helpdesk registry, no target-management UI, and no multi-provider framework in this slice. +**Organization**: Tasks are grouped by user story so create, link, and explicit-failure behavior can be implemented and validated independently once the shared foundation exists. + +## Phase 1: Setup (Shared Preparation) + +**Purpose**: Lock the bounded repo-grounded scope before runtime work begins. + +- [x] T001 Review `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md` and confirm the slice stays one-way, single-target, and SupportRequest-backed. +- [x] T002 [P] Verify the exact reuse seams from Spec 246 in `apps/platform/app/Models/SupportRequest.php`, `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and the new app-config seam `apps/platform/config/support_desk.php` before adding any new handoff behavior. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the bounded persistence, target-resolution, audit, and shared summary seams that every story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T003 Extend the existing support-request truth with `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary` in `apps/platform/database/migrations/*_add_external_handoff_fields_to_support_requests_table.php`, `apps/platform/app/Models/SupportRequest.php`, and `apps/platform/database/factories/SupportRequestFactory.php` without creating a second support-ticket model or table. +- [x] T004 [P] Add the one concrete provider-owned handoff seam and the single-target app-config contract in `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php` and `apps/platform/config/support_desk.php`, enforce the five-second outbound timeout there, and avoid introducing a support settings UI, provider registry, or generic helpdesk framework. +- [x] T005 [P] Preserve the existing `support_request.created` audit path and add stable audit action IDs plus bounded audit payload helpers for external ticket created, external ticket linked, and external handoff failed in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`. +- [x] T006 Add one shared latest-handoff summary read path for tenant and run primary contexts in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so both existing support actions reuse the same scoped query, naming, and no-cross-scope-shortcut rules. + +**Checkpoint**: Foundation ready. The existing support-request path can now persist neutral external-linkage truth, resolve one target, and read the latest scoped handoff summary. + +--- + +## Phase 3: User Story 1 - Create A New External Ticket From The Existing Support Flow (Priority: P1) 🎯 MVP + +**Goal**: An entitled operator can submit the existing support action and create one external desk ticket from the current tenant or run context without leaving the product. + +**Independent Test**: Submit `Request support` from the tenant dashboard and the operation-run viewer with `create_external_ticket`, fake one configured target, and verify the same `SupportRequest` row keeps the internal `SR-...` reference while storing the returned external reference and URL. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Add unit coverage for `create_external_ticket` branching, single-target availability fallback, the five-second timeout path, and created-ticket reference or URL normalization in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`. +- [x] T008 [P] [US1] Add feature coverage for tenant and run `create_external_ticket` success paths in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`. + +### Implementation for User Story 1 + +- [x] T009 [US1] Extend `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so internal support-request creation commits first, `create_external_ticket` runs synchronously afterward, and the one allowed Spec 256 finalization write records the external reference or URL back onto the same `SupportRequest` row. +- [x] T010 [US1] Extend the tenant dashboard support action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with handoff-mode choice, target-availability guidance, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, latest-handoff summary copy, and success feedback that shows both internal and external references when a ticket is created. +- [x] T011 [US1] Extend the run-context support action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with the same create flow, scoped latest-handoff summary, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, and success feedback without adding a new run-support surface. +- [x] T012 [US1] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any create-path regressions before moving to the link flow. + +**Checkpoint**: User Story 1 is independently functional when both existing support actions can create one external ticket and immediately show the persisted linkage on the same support-request truth. + +--- + +## Phase 4: User Story 2 - Link An Already-Existing External Ticket During Support Submission (Priority: P1) + +**Goal**: An entitled operator can record an external ticket that already exists without creating a duplicate external case. + +**Independent Test**: Submit the existing tenant and run `Request support` actions with `link_existing_ticket`, provide a valid reference and optional URL, and verify the `SupportRequest` stores that linkage without issuing an external create call. + +### Tests for User Story 2 + +- [x] T013 [P] [US2] Add unit coverage for `link_existing_ticket` reference normalization, optional URL normalization, and invalid-link rejection in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`. +- [x] T014 [P] [US2] Add feature coverage for tenant and run `link_existing_ticket` submissions, including the no-create-call guarantee and linked-flow success feedback that shows both references, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`. + +### Implementation for User Story 2 + +- [x] T015 [US2] Implement `link_existing_ticket` branching, conditional validation, and persisted external reference or URL behavior in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` and `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`. +- [x] T016 [US2] Add conditional external reference and URL inputs plus linked-flow success feedback that shows the internal and external references on the tenant dashboard action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`. +- [x] T017 [US2] Add the same link controls plus linked-flow success feedback to the run-context action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`. +- [x] T018 [US2] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any link-path regressions before moving to failure hardening. + +**Checkpoint**: User Story 2 is independently functional when both existing support actions can link an already-created external ticket without producing a duplicate external create call. + +--- + +## Phase 5: User Story 3 - Keep Failures Explicit, Scoped, And Auditable (Priority: P2) + +**Goal**: External handoff failure remains visible and auditable while the internal support request stays durable and the same tenant or run contexts stay the only visibility surfaces. + +**Independent Test**: Force `create_external_ticket` to fail after internal request creation, then verify the internal `SupportRequest` remains persisted, the current support context shows the latest failure summary for that same support reference, and audit plus authorization behavior stays correct. + +### Tests for User Story 3 + +- [x] T019 [P] [US3] Add unit coverage for latest-handoff summary derivation, latest-per-context selection, and persisted failure-summary semantics in `apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`. +- [x] T020 [P] [US3] Add feature coverage for tenant and run failed-create partial-success behavior, including timeout-normalized failure feedback, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`. +- [x] T021 [P] [US3] Add feature coverage for `404` versus `403` boundaries and context-scoped latest-summary visibility in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php`. +- [x] T022 [P] [US3] Add feature coverage for preserved `support_request.created` auditing plus created, linked, and failed external-handoff audit events in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`. + +### Implementation for User Story 3 + +- [x] T023 [US3] Persist bounded `external_handoff_failure_summary` semantics, the one allowed Spec 256 finalization-write contract, and latest-summary scoping rules in `apps/platform/app/Models/SupportRequest.php` and `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` without adding support history pages, external-reference lookup routes, or a second support product surface. +- [x] T024 [US3] Implement explicit partial-success or warning feedback plus revisit-time failure-summary rendering in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`. +- [x] T025 [US3] Enforce the shared authorization and audit boundary for create, link, failure, and latest-summary visibility in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` without introducing queues, retries, or `OperationRun` orchestration. +- [x] T026 [US3] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` and fix any failure, audit, or authorization regressions before final polish. + +**Checkpoint**: User Story 3 is independently functional when explicit failure truth, scoped visibility, and audit coverage all hold without losing the internal support request. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Close the slice without widening scope, and leave a clean validation and guardrail trail for review. + +- [x] T027 Confirm `Request support`, `Support reference`, `External ticket`, handoff-mode labels, mutation-scope wording, and latest-summary copy stay aligned across `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` without leaking provider-specific product names into primary operator copy. +- [x] T028 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` on the touched platform files before final validation. +- [x] T029 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` as the focused unit close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`. +- [x] T030 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` as the focused feature close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`. +- [x] T031 Execute the manual smoke path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md` for tenant and run create, link, and failure handling, including the no-new-support-surface and no-`OperationRun` checks. Completed through a temporary Pest Browser smoke harness covering tenant create, run link, run failure, latest failure summary, no console errors, and no persistent browser-test surface. +- [x] T032 Record the final implementation close-out in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, including the guardrail outcome and any explicit `document-in-feature` or named `follow-up-spec` decision for target configuration, retry pressure, or multi-provider pressure instead of hiding that scope in code review. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 starts immediately. +- Phase 2 depends on Phase 1 and blocks all story work. +- Phase 3 depends on Phase 2 and delivers the MVP create flow. +- Phase 4 depends on Phase 2 and is safest after Phase 3 because it extends the same submission service and the same two action forms. +- Phase 5 depends on Phases 3 and 4 because failure, visibility, and audit proof must cover both create and link behavior on both existing surfaces. +- Phase 6 depends on every prior phase. + +### User Story Dependencies + +- US1 is the MVP and first shippable increment. +- US2 is independently testable but should follow US1 because both stories extend the same `SupportRequestSubmissionService` and support-action forms. +- US3 depends on US1 and US2 because explicit failure, audit, and scoped-visibility rules must cover every handoff mode. + +### Within Each User Story + +- Write the listed Pest coverage first and ensure it fails before implementation. +- Land shared submission-service changes before surface wiring whenever both are required. +- Re-run the story-specific validation task before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T001 and T002 can run in parallel. + +### Phase 2 + +- T004 and T005 can run in parallel after T003 establishes the persisted handoff fields. + +### User Story 1 + +- T007 and T008 can run in parallel before implementation work starts. + +### User Story 2 + +- T013 and T014 can run in parallel before implementation work starts. + +### User Story 3 + +- T019, T020, T021, and T022 can run in parallel before the failure hardening pass. + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1. +2. Complete Phase 2. +3. Complete Phase 3. +4. Stop and review the create-only external handoff slice before adding link and failure hardening. + +### Incremental Delivery + +1. Ship US1 so the product can create one external ticket from the two existing support-aware surfaces. +2. Add US2 so operators can link an already-opened external ticket without duplicate create behavior. +3. Add US3 so failure honesty, scoped visibility, and audit proof hold across every handoff mode. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize test authoring inside each story. +3. Sequence merges carefully around `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/lang/en/localization.php` plus `apps/platform/lang/de/localization.php`, because every story touches those same shared seams. -- 2.45.2 From 926b0fe4f34ec3964bdf27b79d482a1d08e5b737 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 22:36:05 +0000 Subject: [PATCH 7/7] feat(specs/257): governance decision convergence (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatisch erstellter PR: Implementiert Spec 257 — Governance decision convergence. Branch: 257-governance-decision-convergence Bitte Review und Merge gegen `platform-dev`. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/304 --- .../Pages/Findings/FindingsIntakeQueue.php | 35 +- .../Pages/Findings/MyFindingsInbox.php | 35 +- .../Pages/Governance/GovernanceInbox.php | 34 +- .../Monitoring/FindingExceptionsQueue.php | 41 ++- .../Pages/Reviews/CustomerReviewWorkspace.php | 67 +++- .../GovernanceInboxSectionBuilder.php | 235 +++++++++++-- .../Navigation/CanonicalNavigationContext.php | 21 ++ .../governance/governance-inbox.blade.php | 4 +- ...ndingsIntakeQueueNavigationContextTest.php | 58 ++++ .../MyFindingsInboxNavigationContextTest.php | 56 +++ ...eInboxNavigationContextConvergenceTest.php | 54 +++ .../Governance/GovernanceInboxPageTest.php | 73 +++- ...ngExceptionsQueueNavigationContextTest.php | 85 +++++ ...erReviewWorkspaceNavigationContextTest.php | 61 ++++ .../CanonicalNavigationContextTest.php | 23 ++ .../GovernanceInboxSectionBuilderTest.php | 91 ++++- .../checklists/requirements.md | 37 ++ .../plan.md | 254 ++++++++++++++ .../spec.md | 320 ++++++++++++++++++ .../tasks.md | 189 +++++++++++ 20 files changed, 1712 insertions(+), 61 deletions(-) create mode 100644 apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php create mode 100644 apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php create mode 100644 specs/257-governance-decision-convergence/checklists/requirements.md create mode 100644 specs/257-governance-decision-convergence/plan.md create mode 100644 specs/257-governance-decision-convergence/spec.md create mode 100644 specs/257-governance-decision-convergence/tasks.md diff --git a/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php b/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php index cb1e37a8..20e538b7 100644 --- a/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php +++ b/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php @@ -105,14 +105,26 @@ public function mount(): void protected function getHeaderActions(): array { - return [ - Action::make('clear_tenant_filter') - ->label('Clear tenant filter') - ->icon('heroicon-o-x-mark') + $actions = []; + + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') ->color('gray') - ->visible(fn (): bool => $this->currentTenantFilterId() !== null) - ->action(fn (): mixed => $this->clearTenantFilter()), - ]; + ->url($governanceContext->backLinkUrl); + } + + $actions[] = Action::make('clear_tenant_filter') + ->label('Clear tenant filter') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->currentTenantFilterId() !== null) + ->action(fn (): mixed => $this->clearTenantFilter()); + + return $actions; } public function table(Table $table): Table @@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext ); } + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = CanonicalNavigationContext::fromRequest(request()); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + private function queueUrl(array $overrides = []): string { $resolvedTenant = array_key_exists('tenant', $overrides) diff --git a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php index e2d547cc..15f9a7b7 100644 --- a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +++ b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php @@ -97,14 +97,26 @@ public function mount(): void protected function getHeaderActions(): array { - return [ - Action::make('clear_tenant_filter') - ->label('Clear tenant filter') - ->icon('heroicon-o-x-mark') + $actions = []; + + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') ->color('gray') - ->visible(fn (): bool => $this->currentTenantFilterId() !== null) - ->action(fn (): mixed => $this->clearTenantFilter()), - ]; + ->url($governanceContext->backLinkUrl); + } + + $actions[] = Action::make('clear_tenant_filter') + ->label('Clear tenant filter') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->currentTenantFilterId() !== null) + ->action(fn (): mixed => $this->clearTenantFilter()); + + return $actions; } public function table(Table $table): Table @@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext ); } + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = CanonicalNavigationContext::fromRequest(request()); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + private function queueUrl(): string { $tenant = $this->filteredTenant(); diff --git a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php index 7069a0ae..dc13ea03 100644 --- a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php +++ b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php @@ -75,6 +75,8 @@ class GovernanceInbox extends Page private ?bool $visibleAlertsFamily = null; + private ?bool $visibleFindingExceptionsFamily = null; + public ?int $tenantId = null; public ?string $family = null; @@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string public function navigationContext(): CanonicalNavigationContext { - return new CanonicalNavigationContext( - sourceSurface: 'governance.inbox', + return CanonicalNavigationContext::forGovernanceInbox( canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), tenantId: $this->tenantId, - backLinkLabel: 'Back to governance inbox', backLinkUrl: $this->pageUrl(), + familyKey: $this->family, ); } @@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void if ( $this->hasVisibleOperationsFamily() || $this->visibleFindingTenants() !== [] + || $this->hasVisibleFindingExceptionsFamily() || $this->reviewTenants() !== [] || $this->hasVisibleAlertsFamily() ) { @@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); } + private function hasVisibleFindingExceptionsFamily(): bool + { + if (is_bool($this->visibleFindingExceptionsFamily)) { + return $this->visibleFindingExceptionsFamily; + } + + if ($this->authorizedTenants() === []) { + return $this->visibleFindingExceptionsFamily = false; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->visibleFindingExceptionsFamily = false; + } + + return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class) + ->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE); + } + /** * @return array */ @@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string return in_array($family, [ 'assigned_findings', 'intake_findings', + 'finding_exceptions', 'stale_operations', 'alert_delivery_failures', 'review_follow_up', @@ -424,6 +448,7 @@ private function inboxPayload(): array visibleFindingTenants: $this->visibleFindingTenants(), reviewTenants: $this->reviewTenants(), canViewAlerts: $this->hasVisibleAlertsFamily(), + canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(), selectedTenant: $this->selectedTenant(), selectedFamily: $this->family, navigationContext: $this->navigationContext(), @@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array visibleFindingTenants: $this->visibleFindingTenants(), reviewTenants: $this->reviewTenants(), canViewAlerts: $this->hasVisibleAlertsFamily(), + canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(), selectedTenant: null, selectedFamily: null, navigationContext: $this->navigationContext(), @@ -491,4 +517,4 @@ private function tenantFilterAloneExcludesRows(): bool return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0; } -} \ No newline at end of file +} diff --git a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php index 57d9d349..a5281933 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php +++ b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php @@ -208,6 +208,16 @@ protected function getHeaderActions(): array returnActionName: 'operate_hub_return_finding_exceptions', ); + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url($governanceContext->backLinkUrl); + } + $actions[] = Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') @@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string return null; } - return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant); + return $this->appendQuery( + FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant), + $this->navigationContext()?->toQuery() ?? [], + ); } public function selectedFindingUrl(): ?string @@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string return null; } - return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant); + return $this->appendQuery( + FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant), + $this->navigationContext()?->toQuery() ?? [], + ); } public function clearSelectedException(): void @@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext return CanonicalNavigationContext::fromRequest(request()); } + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = $this->navigationContext(); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + private function normalizeSelectedFindingExceptionId(): void { if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) { @@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string return 'danger'; } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } } diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 520f4c52..092f4f6f 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -15,6 +15,7 @@ use App\Support\Auth\Capabilities; use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\TablePaginationProfiles; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\ReviewPackStatus; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; @@ -112,16 +113,28 @@ public function mount(): void protected function getHeaderActions(): array { - return [ - Action::make('clear_filters') - ->label(__('localization.review.clear_filters')) - ->icon('heroicon-o-x-mark') + $actions = []; + + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') ->color('gray') - ->visible(fn (): bool => $this->hasActiveFilters()) - ->action(function (): void { - $this->clearWorkspaceFilters(); - }), - ]; + ->url($governanceContext->backLinkUrl); + } + + $actions[] = Action::make('clear_filters') + ->label(__('localization.review.clear_filters')) + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->hasActiveFilters()) + ->action(function (): void { + $this->clearWorkspaceFilters(); + }); + + return $actions; } public function table(Table $table): Table @@ -348,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string return null; } - return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([ - self::DETAIL_CONTEXT_QUERY_KEY => 1, - ]); + return $this->appendQuery( + TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'), + array_replace( + [self::DETAIL_CONTEXT_QUERY_KEY => 1], + $this->navigationContext()?->toQuery() ?? [], + ), + ); } private function latestReviewPack(Tenant $tenant): ?ReviewPack @@ -527,4 +544,30 @@ private function reviewPackAvailability(Tenant $tenant): string return __('localization.review.available'); } + + private function navigationContext(): ?CanonicalNavigationContext + { + return CanonicalNavigationContext::fromRequest(request()); + } + + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = $this->navigationContext(); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } } diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php index bc8e778d..bc91953a 100644 --- a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -6,14 +6,15 @@ use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\MyFindingsInbox; +use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingResource; -use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantReviewResource; use App\Models\AlertDelivery; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\TenantTriageReview; @@ -21,14 +22,12 @@ use App\Models\Workspace; use App\Services\TenantReviews\TenantReviewRegisterService; use App\Support\Auth\Capabilities; -use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthResolver; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationRunLinks; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\RestoreSafety\RestoreSafetyResolver; -use App\Support\Tenants\TenantRecoveryTriagePresentation; use Illuminate\Support\Str; final readonly class GovernanceInboxSectionBuilder @@ -41,6 +40,7 @@ private const FAMILY_ORDER = [ 'assigned_findings', 'intake_findings', + 'finding_exceptions', 'stale_operations', 'alert_delivery_failures', 'review_follow_up', @@ -71,6 +71,7 @@ public function build( array $visibleFindingTenants, array $reviewTenants, bool $canViewAlerts, + bool $canViewFindingExceptions = false, ?Tenant $selectedTenant = null, ?string $selectedFamily = null, ?CanonicalNavigationContext $navigationContext = null, @@ -113,6 +114,22 @@ public function build( } if ($authorizedTenantsById !== []) { + if ($canViewFindingExceptions) { + $findingExceptionsSection = $this->findingExceptionsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$findingExceptionsSection['key']] = $findingExceptionsSection; + $availableFamilies[] = [ + 'key' => $findingExceptionsSection['key'], + 'label' => $findingExceptionsSection['label'], + 'count' => $findingExceptionsSection['count'], + ]; + $familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count']; + } + $operationsSection = $this->operationsSection( workspace: $workspace, authorizedTenants: $authorizedTenantsById, @@ -191,6 +208,59 @@ public function build( ]; } + /** + * @param array $authorizedTenants + * @return array + */ + private function findingExceptionsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $pendingCount = (clone $baseQuery) + ->where('status', FindingException::STATUS_PENDING) + ->count(); + $expiringCount = (clone $baseQuery) + ->where('current_validity_state', FindingException::VALIDITY_EXPIRING) + ->count(); + $lapsedCount = (clone $baseQuery) + ->where('status', '!=', FindingException::STATUS_PENDING) + ->whereIn('current_validity_state', [ + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + ]) + ->count(); + $entries = $this->orderedFindingExceptionsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext)) + ->all(); + + return [ + 'key' => 'finding_exceptions', + 'label' => 'Finding exceptions', + 'count' => $count, + 'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount), + 'dominant_action_label' => 'Open finding exceptions', + 'dominant_action_url' => $this->appendQuery( + FindingExceptionsQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + ], static fn (mixed $value): bool => is_string($value) && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No finding exceptions match this tenant filter right now.' + : 'No finding exceptions need review right now.', + ]; + } + /** * @param array $tenants * @return array @@ -477,28 +547,10 @@ private function reviewFollowUpSection( 'label' => 'Review follow-up', 'count' => count($rawEntries), 'summary' => $this->reviewSummary($followUpCount, $changedCount), - 'dominant_action_label' => 'Open review follow-up', + 'dominant_action_label' => 'Open customer review workspace', 'dominant_action_url' => $selectedTenant instanceof Tenant ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) - : $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( - $navigationContext?->toQuery() ?? [], - [ - 'backup_posture' => [ - TenantBackupHealthAssessment::POSTURE_ABSENT, - TenantBackupHealthAssessment::POSTURE_STALE, - TenantBackupHealthAssessment::POSTURE_DEGRADED, - ], - 'recovery_evidence' => [ - TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED, - TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED, - ], - 'review_state' => [ - TenantTriageReview::STATE_FOLLOW_UP_NEEDED, - TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, - ], - 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, - ], - )), + : $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []), 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), 'empty_state' => $selectedTenant instanceof Tenant ? 'No review follow-up is visible for this tenant filter right now.' @@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te }); } + /** + * @param array $authorizedTenants + */ + private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($authorizedTenants); + + return FindingException::query() + ->with([ + 'tenant', + 'requester:id,name', + 'owner:id,name', + 'finding' => fn ($query) => $query->withSubjectDisplayName(), + ]) + ->where('workspace_id', (int) $workspace->getKey()) + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->where(function ($query): void { + $query + ->where('status', FindingException::STATUS_PENDING) + ->orWhereIn('status', [ + FindingException::STATUS_EXPIRING, + FindingException::STATUS_EXPIRED, + ]) + ->orWhereIn('current_validity_state', [ + FindingException::VALIDITY_EXPIRING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + ]); + }); + } + + private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + "case + when status = ? then 0 + when current_validity_state = ? then 1 + when current_validity_state = ? then 2 + when current_validity_state = ? then 3 + else 4 + end asc", + [ + FindingException::STATUS_PENDING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + FindingException::VALIDITY_EXPIRING, + ], + ) + ->orderByRaw('case when review_due_at is null then 1 else 0 end asc') + ->orderBy('review_due_at') + ->orderByDesc('id'); + } + /** * @return array */ @@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext ]; } + /** + * @return array + */ + private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array + { + $findingLabel = $exception->finding?->resolvedSubjectDisplayName() + ?? 'Finding #'.$exception->finding_id; + $sublineParts = array_values(array_filter([ + $exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null, + FindingExceptionResource::relativeTimeDescription($exception->review_due_at) + ?? FindingExceptionResource::relativeTimeDescription($exception->expires_at), + is_string($exception->request_reason) && $exception->request_reason !== '' + ? $exception->request_reason + : null, + ])); + + return [ + 'family_key' => 'finding_exceptions', + 'source_model' => FindingException::class, + 'source_key' => (string) $exception->getKey(), + 'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null, + 'tenant_label' => $exception->tenant?->name, + 'headline' => $findingLabel, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => match (true) { + (string) $exception->status === FindingException::STATUS_PENDING => 0, + (string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1, + (string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2, + (string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3, + default => 4, + }, + 'status_label' => $this->findingExceptionStatusLabel($exception), + 'destination_url' => $this->appendQuery( + FindingExceptionsQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $exception->tenant?->external_id, + 'exception' => (int) $exception->getKey(), + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + /** * @param array $row * @return array @@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string ); } + private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string + { + if ($count === 0) { + return 'No finding exceptions need review in the current scope.'; + } + + return sprintf( + '%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.', + $count, + $count === 1 ? '' : 's', + $pendingCount, + $expiringCount, + $lapsedCount, + ); + } + + private function findingExceptionStatusLabel(FindingException $exception): string + { + if ((string) $exception->status === FindingException::STATUS_PENDING) { + return 'Pending'; + } + + if (in_array((string) $exception->current_validity_state, [ + FindingException::VALIDITY_EXPIRING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + ], true)) { + return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value(); + } + + return Str::of((string) $exception->status)->replace('_', ' ')->title()->value(); + } + private function reviewSummary(int $followUpCount, int $changedCount): string { $total = $followUpCount + $changedCount; @@ -885,4 +1072,4 @@ private function appendQuery(string $url, array $query): string return $url.$separator.http_build_query($query); } -} \ No newline at end of file +} diff --git a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php index 4f4c80a4..66b33c15 100644 --- a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php +++ b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php @@ -18,6 +18,7 @@ public function __construct( public string $sourceSurface, public string $canonicalRouteName, public ?int $tenantId = null, + public ?string $familyKey = null, public ?string $backLinkLabel = null, public ?string $backLinkUrl = null, public array $filterPayload = [], @@ -56,12 +57,31 @@ public static function fromRequest(Request $request): ?self sourceSurface: $sourceSurface, canonicalRouteName: $canonicalRouteName, tenantId: is_numeric($tenantId) ? (int) $tenantId : null, + familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== '' + ? (string) $payload['family_key'] + : null, backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null, backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null, filterPayload: [], ); } + public static function forGovernanceInbox( + string $canonicalRouteName, + ?int $tenantId, + ?string $familyKey, + string $backLinkUrl, + ): self { + return new self( + sourceSurface: 'governance.inbox', + canonicalRouteName: $canonicalRouteName, + tenantId: $tenantId, + familyKey: $familyKey, + backLinkLabel: 'Back to governance inbox', + backLinkUrl: $backLinkUrl, + ); + } + /** * @return array */ @@ -117,6 +137,7 @@ private function navPayload(): array 'source_surface' => $this->sourceSurface, 'canonical_route_name' => $this->canonicalRouteName, 'tenant_id' => $this->tenantId, + 'family_key' => $this->familyKey, 'back_label' => $this->backLinkLabel, 'back_url' => $this->backLinkUrl, ], static fn (mixed $value): bool => $value !== null && $value !== ''); diff --git a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php index 6280e61e..2a6c5af9 100644 --- a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php +++ b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php @@ -18,7 +18,7 @@

      - This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. + This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.

      @@ -161,4 +161,4 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo @endforeach @endif - \ No newline at end of file + diff --git a/apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php new file mode 100644 index 00000000..ba5b2476 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php @@ -0,0 +1,58 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + $finding = Finding::factory() + ->for($tenant) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'assignee_user_id' => null, + 'status' => Finding::STATUS_NEW, + 'subject_external_id' => 'intake-from-governance', + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'intake_findings', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'intake_findings', + ]), + ); + + Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + 'view' => 'needs_triage', + ])) + ->actingAs($user) + ->test(FindingsIntakeQueue::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertCanSeeTableRecords([$finding]) + ->assertSee('Shared unassigned work') + ->assertDontSee('This workspace decision surface routes you'); +}); diff --git a/apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php b/apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php new file mode 100644 index 00000000..41647011 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php @@ -0,0 +1,56 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_external_id' => 'assigned-from-governance', + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'assigned_findings', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'assigned_findings', + ]), + ); + + Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + ])) + ->actingAs($user) + ->test(MyFindingsInbox::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertCanSeeTableRecords([Finding::query()->where('subject_external_id', 'assigned-from-governance')->firstOrFail()]) + ->assertSee('Assigned to me only') + ->assertDontSee('This workspace decision surface routes you'); +}); diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php new file mode 100644 index 00000000..7b466d79 --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php @@ -0,0 +1,54 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager'); + + $finding = Finding::factory() + ->for($tenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_external_id' => 'governance-exception-lane', + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Governance convergence request', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'); + + $response->assertOk() + ->assertSee('Finding exceptions') + ->assertSee('Open finding exceptions') + ->assertSee('Governance convergence request') + ->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false) + ->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false) + ->assertSee('exception='.(string) $exception->getKey(), false) + ->assertDontSee('Open my findings') + ->assertDontSee('Open findings intake'); +}); diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php index 4f3a33a0..52610a5d 100644 --- a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php @@ -5,6 +5,7 @@ use App\Filament\Pages\Governance\GovernanceInbox; use App\Models\AlertDelivery; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\TenantTriageReview; @@ -46,6 +47,28 @@ ->reopened() ->create(); + $exceptionFinding = Finding::factory() + ->for($alphaTenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'subject_external_id' => 'exception-governance-home', + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'tenant_id' => (int) $alphaTenant->getKey(), + 'finding_id' => (int) $exceptionFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Governance home exception review', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + OperationRun::factory() ->forTenant($alphaTenant) ->create([ @@ -87,13 +110,15 @@ ->assertOk() ->assertSee('Assigned findings') ->assertSee('Findings intake') + ->assertSee('Finding exceptions') ->assertSee('Operations follow-up') ->assertSee('Alert delivery failures') ->assertSee('Review follow-up') ->assertSee('Open my findings') + ->assertSee('Open finding exceptions') ->assertSee('Open terminal follow-up') ->assertSee('Open alert deliveries') - ->assertSee('Open review follow-up'); + ->assertSee('Open customer review workspace'); }); it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { @@ -140,4 +165,48 @@ ->assertSee('Alert delivery failures') ->assertSee('No failed alert deliveries match this tenant filter right now.') ->assertDontSee('Open my findings'); -}); \ No newline at end of file +}); + +it('omits the finding exceptions lane when the workspace capability is not visible', function (): void { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly'); + + Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $exceptionFinding = Finding::factory() + ->for($tenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $exceptionFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Hidden exception lane', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Assigned findings') + ->assertDontSee('Finding exceptions') + ->assertDontSee('Hidden exception lane'); +}); diff --git a/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php new file mode 100644 index 00000000..a0ef2c0b --- /dev/null +++ b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php @@ -0,0 +1,85 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager'); + + $finding = Finding::factory() + ->for($tenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_external_id' => 'exception-secondary-finding', + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Exception queue return context', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'finding_exceptions', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'finding_exceptions', + ]), + ); + + $component = Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + 'exception' => (int) $exception->getKey(), + ])) + ->actingAs($user) + ->test(FindingExceptionsQueue::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertSet('selectedFindingExceptionId', (int) $exception->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertActionVisible('open_selected_exception') + ->assertActionVisible('open_selected_finding') + ->assertSee('Exception queue return context') + ->assertSee('Focused review lane') + ->assertDontSee('This workspace decision surface routes you'); + + expect($component->instance()->selectedExceptionUrl()) + ->toContain('nav%5Bsource_surface%5D=governance.inbox') + ->toContain('nav%5Bfamily_key%5D=finding_exceptions') + ->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey()); + + expect($component->instance()->selectedFindingUrl()) + ->toContain('nav%5Bsource_surface%5D=governance.inbox') + ->toContain('nav%5Bfamily_key%5D=finding_exceptions') + ->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey()); +}); diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php new file mode 100644 index 00000000..230ca340 --- /dev/null +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php @@ -0,0 +1,61 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'generated_at' => now(), + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'review_follow_up', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'review_follow_up', + ]), + ); + + Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + ])) + ->actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertCanSeeTableRecords([$tenant->fresh()]) + ->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false) + ->assertSee(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY.'=1', false) + ->assertSee('nav%5Bsource_surface%5D=governance.inbox', false) + ->assertSee('nav%5Bfamily_key%5D=review_follow_up', false) + ->assertDontSee('This workspace decision surface routes you'); +}); diff --git a/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php b/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php index 4ccb2ddb..5e09b839 100644 --- a/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php +++ b/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php @@ -52,3 +52,26 @@ ->and($context?->backLinkLabel)->toBe('Back to backup set') ->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8'); }); + +it('serializes governance inbox family context for secondary surface return links', function (): void { + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: 'filament.admin.pages.governance.inbox', + tenantId: 12, + familyKey: 'finding_exceptions', + backLinkUrl: '/admin/governance/inbox?tenant_id=12&family=finding_exceptions', + ); + + $roundTrip = CanonicalNavigationContext::fromRequest(Request::create('/admin/finding-exceptions/queue', 'GET', $context->toQuery())); + + expect($context->toQuery()['nav']) + ->toMatchArray([ + 'source_surface' => 'governance.inbox', + 'tenant_id' => 12, + 'family_key' => 'finding_exceptions', + 'back_label' => 'Back to governance inbox', + ]) + ->and($roundTrip?->sourceSurface)->toBe('governance.inbox') + ->and($roundTrip?->tenantId)->toBe(12) + ->and($roundTrip?->familyKey)->toBe('finding_exceptions') + ->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?tenant_id=12&family=finding_exceptions'); +}); diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php index 68a556de..c03db279 100644 --- a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -4,6 +4,7 @@ use App\Models\AlertDelivery; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\TenantTriageReview; @@ -54,6 +55,28 @@ 'subject_external_id' => 'intake-finding', ]); + $exceptionFinding = Finding::factory() + ->for($alphaTenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'subject_external_id' => 'exception-finding', + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $alphaTenant->getKey(), + 'finding_id' => (int) $exceptionFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Needs approval', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + OperationRun::factory() ->forTenant($alphaTenant) ->create([ @@ -129,6 +152,7 @@ visibleFindingTenants: [$alphaTenant, $bravoTenant], reviewTenants: [$alphaTenant, $bravoTenant], canViewAlerts: true, + canViewFindingExceptions: true, navigationContext: $context, ); @@ -136,6 +160,7 @@ ->toBe([ 'assigned_findings', 'intake_findings', + 'finding_exceptions', 'stale_operations', 'alert_delivery_failures', 'review_follow_up', @@ -143,6 +168,7 @@ ->and($payload['family_counts'])->toMatchArray([ 'assigned_findings' => 1, 'intake_findings' => 1, + 'finding_exceptions' => 1, 'stale_operations' => 2, 'alert_delivery_failures' => 1, 'review_follow_up' => 2, @@ -153,6 +179,9 @@ expect($sections['assigned_findings']['dominant_action_url']) ->toContain('/admin/findings/my-work') ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') + ->and($sections['finding_exceptions']['dominant_action_label'])->toBe('Open finding exceptions') + ->and($sections['finding_exceptions']['dominant_action_url'])->toContain('/admin/finding-exceptions/queue') + ->and($sections['finding_exceptions']['entries'][0]['destination_url'])->toContain('exception='.(string) $sections['finding_exceptions']['entries'][0]['source_key']) ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') @@ -196,4 +225,64 @@ ->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures') ->and($payload['sections'][0]['count'])->toBe(0) ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); -}); \ No newline at end of file +}); + +it('omits finding exceptions when the exception family is hidden or tenant scope is inaccessible', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $visibleTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Visible Tenant', + ]); + $hiddenTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Hidden Tenant', + ]); + + $finding = Finding::factory() + ->for($hiddenTenant) + ->riskAccepted() + ->create(['workspace_id' => (int) $workspace->getKey()]); + + FindingException::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $hiddenTenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Hidden request', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $builder = app(GovernanceInboxSectionBuilder::class); + + $payloadWithoutCapability = $builder->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$visibleTenant, $hiddenTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: false, + canViewFindingExceptions: false, + ); + + $payloadWithHiddenTenantOnly = $builder->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$visibleTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: false, + canViewFindingExceptions: true, + ); + + expect(collect($payloadWithoutCapability['available_families'])->pluck('key')->all()) + ->not->toContain('finding_exceptions') + ->and($payloadWithHiddenTenantOnly['family_counts']['finding_exceptions'] ?? null)->toBe(0) + ->and($payloadWithHiddenTenantOnly['sections'])->toBe([]); +}); diff --git a/specs/257-governance-decision-convergence/checklists/requirements.md b/specs/257-governance-decision-convergence/checklists/requirements.md new file mode 100644 index 00000000..6c89cc05 --- /dev/null +++ b/specs/257-governance-decision-convergence/checklists/requirements.md @@ -0,0 +1,37 @@ +# Preparation Review Checklist: Governance Decision Surface Convergence v1 + +**Purpose**: Verify that the preparation package is repo-grounded, narrowly scoped, and ready for a later implementation loop. +**Created**: 2026-04-29 +**Review outcome class**: Workflow Compression +**Workflow outcome**: approve for implementation +**Test-governance outcome**: keep + +## Candidate Selection + +- [x] CHK001 The selected slice is anchored to `docs/product/roadmap.md` (`Decision-Based Operating Foundations`) and the open-gap truth in `docs/product/implementation-ledger.md`. +- [x] CHK002 Already-specced candidates such as Spec 043, Spec 249, Spec 250, Spec 251, Spec 252, Spec 253, Spec 254, Spec 255, and Spec 256 are explicitly excluded from reopening. +- [x] CHK003 The package chooses the smallest repo-grounded slice: extend the existing governance inbox and specialist pages instead of inventing a new shell or workflow engine. + +## Scope And Truth + +- [x] CHK004 The spec, plan, and tasks all state that no new persistence, inbox-item truth, queue state, or mutation lane is introduced. +- [x] CHK005 The governance inbox remains the canonical workspace decision home, while specialist queues and the customer review workspace remain secondary-context surfaces. +- [x] CHK006 The finding-exceptions lane and review-consumption handoff are described as derived from existing repo truth rather than a new abstraction layer. + +## UX And Authorization + +- [x] CHK007 The package makes one dominant next action explicit for the governance home, preserves one dominant default action on each specialist surface, and keeps specialist pages from duplicating the workspace-level summary. +- [x] CHK008 `404` vs `403` semantics are explicit for workspace membership, tenant scope, and no-visible-family cases. +- [x] CHK009 Hidden tenants and hidden families are omitted from counts, labels, previews, and empty-state hints. + +## Test Governance + +- [x] CHK010 Planned proof stays in focused `Unit` plus `Feature` lanes only, with explicit validation commands. +- [x] CHK011 The tasks include explicit coverage for family assembly, arrival/return continuity, latest-published-review preference versus workspace fallback, duplicate-truth prevention, and read-only review integrity on the specialist pages. +- [x] CHK012 The checklist records the active review outcome class and workflow outcome instead of leaving readiness implicit. + +## Readiness Outcome + +- [x] CHK013 The package is ready for implementation only if analysis confirms that the scope remains bounded to existing governance, findings, monitoring, and review seams and that `CustomerReviewWorkspace` stays read-only. +- [x] CHK014 The package explicitly preserves the no-new-Graph-call, no-queue, no-`OperationRun`, and no-new-audit-stream constraints for this slice. +- [x] CHK015 Any broader dashboard-entry, cross-tenant, or workflow-engine follow-up is listed separately rather than hidden inside this slice. diff --git a/specs/257-governance-decision-convergence/plan.md b/specs/257-governance-decision-convergence/plan.md new file mode 100644 index 00000000..9ddc3200 --- /dev/null +++ b/specs/257-governance-decision-convergence/plan.md @@ -0,0 +1,254 @@ +# Implementation Plan: Governance Decision Surface Convergence v1 + +**Branch**: `257-governance-decision-convergence` | **Date**: 2026-04-29 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +## Summary + +Tighten TenantPilot's decision-first operating model by converging onto the existing `GovernanceInbox` as the canonical workspace decision home, extending it with the still-missing finding-exceptions lane, and aligning the specialist findings, exceptions, and customer-review pages behind one truthful arrival and return model. The slice is intentionally read-only and reuses existing page, builder, and navigation seams instead of adding a new page shell, workflow state, or task engine. + +Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no new asset bundle is expected. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing governance inbox and navigation helpers +**Storage**: PostgreSQL via existing findings, finding-exceptions, reviews, packs, alerts, and operation-run truth only +**Testing**: Pest v4 `Unit` plus `Feature` coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) +**Project Type**: Web application (Laravel monolith with Filament pages) +**Performance Goals**: derived DB-only page rendering, no new remote calls, and no queue or `OperationRun` start in v1 +**Constraints**: no new persistence, no new page shell, no new mutation lane, no customer portal scope, no duplicate truth across equal-priority cards +**Scale/Scope**: 1 existing canonical page, 4 specialist page classes plus their Blade views, and 1 bounded section-builder extension + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` +- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` +- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` +- `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php` +- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` +- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php` +- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` +- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` +- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` +- `apps/platform/app/Support/OperateHub/OperateHubShell.php` +- `apps/platform/app/Support/Badges/BadgeRenderer.php` + +## UI / Filament & Livewire Fit + +- Reuse the existing `GovernanceInbox` Filament page instead of introducing a new page class or a utility shell. +- Keep the governance home as a section-based read-only page. Add one derived exception lane and adjust review-consumption handoff copy and arrival context, but keep diagnostics and proof on the existing specialist or detail routes. +- Preserve specialist-page ownership. `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` remain the pages where lane-specific truth and existing safe actions live. +- Any state that must survive navigation or Livewire requests stays on public, query-backed, or existing session-backed state. Do not move convergence state into private page properties. +- No new resource, global-search result, or panel asset registration is planned. + +## RBAC / Policy Fit + +- Workspace membership remains the first gate for the governance home and all converged routes. +- Findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW`; existing inline safe actions such as claim remain on the owning specialist pages and continue to require their existing capabilities such as `Capabilities::TENANT_FINDINGS_ASSIGN`. +- The exception lane must reuse the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE` rather than inventing a second exception-view capability. +- The review-consumption handoff must reuse the current review and review-pack access rules instead of adding a new customer-review-workspace capability family. +- `404` applies to non-members and out-of-scope tenant targets. `403` applies only to in-scope members who still cannot see any converged family. + +## Audit / Logging Fit + +- The convergence layer stays read-only and should not add a new page-view audit stream. +- Existing mutations and downloads remain audited on their current owning surfaces. +- No new `OperationRun`, notification stream, or navigation-event ledger is required. + +## Data & Query Fit + +- Extend `GovernanceInboxSectionBuilder` rather than creating a new persistence or projection layer. +- The new exception lane must derive from existing `FindingException` truth and the current queue semantics, not from a copied workflow summary. +- Review-consumption handoff should keep using the current latest-published-review vs customer-review-workspace fallback logic. +- Family counts, previews, and empty-state decisions must be computed only after tenant and capability filtering, so hidden tenants and hidden lanes do not leak through aggregate counts. +- Keep any new family key local to the page and builder; do not introduce a new domain enum or persisted state family. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: governance decision home, specialist queues, customer-review routing, navigation continuity +- **State layers in scope**: page, URL-query, table/session restore +- **Audience modes in scope**: operator-MSP, customer-read-only on the existing review workspace +- **Decision/diagnostic/raw hierarchy plan**: decision-first on the governance home, diagnostics-second on specialist pages, raw/support detail remains on existing detail paths only +- **Raw/support gating plan**: hidden by default on the governance home; existing gating remains on source/detail surfaces +- **One-primary-action / duplicate-truth control**: governance home keeps one dominant CTA per section; specialist pages keep their existing lane-owned primary action and must not duplicate the governance-home summary +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none planned +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the specialist findings, exception, and review pages listed above +- **Shared abstractions reused**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing source-page action-surface declarations +- **New abstraction introduced? why?**: at most one bounded convergence helper for arrival and return semantics if the existing navigation-context helper needs a thin extension; no new framework or registry is justified +- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for page ownership and current routing, but not yet sufficient to make the governance home the single truthful start surface across the missing exception and review-consumption lanes +- **Bounded deviation / spread control**: no new shell or workflow engine; all implementation stays inside existing governance, findings, monitoring, reviews, and navigation seams + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` contract change +- **Central contract reused**: the already-existing stale-operations deep-link behavior remains unchanged +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: the governance home continues to list stale operations through the existing family, but this spec does not change that family's start or completion semantics +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing governance and navigation vocabulary only +- **Neutral platform terms / contracts preserved**: `Governance inbox`, `finding exceptions`, `customer review workspace`, `Open lane`, and `Back to governance inbox` +- **Retained provider-specific semantics and why**: none new +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before implementation preparation continues.* + +- Inventory-first: PASS. All sections remain derived from existing findings, exceptions, reviews, alerts, and operation-run truth. +- Read/write separation: PASS. The convergence layer remains read-only and keeps mutations on existing source surfaces. +- Graph contract path: PASS. No new Graph or provider calls are introduced. +- Deterministic capabilities: PASS. Existing capability registries remain authoritative. +- Workspace and tenant isolation: PASS. Workspace membership remains first, and tenant/family omission happens before counts are exposed. +- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion. +- Destructive action discipline: PASS by non-use. No new destructive or risky actions are introduced. +- Global search: PASS. No new resource or search result is added. +- OperationRun / Ops-UX: PASS by non-use. No new run start or completion behavior exists. +- Data minimization: PASS. Default-visible content stays limited to family summaries, lane scope, and next action. +- Test governance: PASS. Proof remains in focused `Unit` and `Feature` lanes. +- Proportionality / no premature abstraction: PASS. The design extends an existing page and builder instead of introducing a new shell or engine. +- Persisted truth: PASS. No new table, artifact, or cached projection is introduced. +- Behavioral state: PASS. Any additional family key remains derived page state only. +- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing page and navigation patterns are extended rather than bypassed. +- Provider boundary: PASS. No provider/platform seam widens. +- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned. + +**Gate evaluation**: PASS. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for section assembly and convergence routing, `Feature` for page visibility, family omission, and navigation continuity +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves derived-family assembly cheaply; feature coverage proves route access, family omission, tenant-prefilter continuity, and duplicate-truth prevention on existing pages +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse existing workspace, tenant, finding, exception, and review fixtures without widening into browser or heavy-governance families +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because return context and tenant-filter continuity are part of the contract +- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the governance home stays read-only, and confirm specialist surfaces preserve lane-specific truth without duplicating the workspace summary +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase +- **Review-stop questions**: lane fit, hidden fixture growth, accidental new shell, accidental new mutation lane, hidden leakage through counts +- **Escalation path**: `document-in-feature` for contained navigation-context notes; `reject-or-split` for any new shell or workflow-engine drift +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Test-governance outcome**: keep +- **Why no dedicated follow-up spec is needed**: the bounded convergence work remains feature-local unless future work demands a broader dashboard or portfolio action-center spec + +## Rollout & Risk Controls + +- Keep the governance inbox as the only primary start surface touched by this slice. +- Keep all specialist mutations on their existing pages. +- Do not widen the exception or review lane into new workflow state. +- Prefer extending the current section builder and navigation helper over adding a new orchestrator. +- Treat any attempt to add a second workspace summary banner on the specialist pages as out-of-scope drift. + +## Project Structure + +### Documentation (this feature) + +```text +specs/257-governance-decision-convergence/ +├── checklists/ +│ └── requirements.md +├── spec.md +├── plan.md +└── tasks.md +``` + +This preparation package intentionally stays on the core artifacts plus the review checklist. The repo truth is already known, the slice adds no new persistence or external contract, and no extra research/data-model/contracts package is required to make the implementation bounded. + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/ +│ │ ├── Findings/ +│ │ │ ├── MyFindingsInbox.php +│ │ │ └── FindingsIntakeQueue.php +│ │ ├── Governance/ +│ │ │ └── GovernanceInbox.php +│ │ ├── Monitoring/ +│ │ │ └── FindingExceptionsQueue.php +│ │ └── Reviews/ +│ │ └── CustomerReviewWorkspace.php +│ └── Support/ +│ ├── GovernanceInbox/ +│ │ └── GovernanceInboxSectionBuilder.php +│ ├── Navigation/ +│ │ └── CanonicalNavigationContext.php +│ └── OperateHub/ +│ └── OperateHubShell.php +└── resources/views/filament/pages/ + ├── findings/ + │ ├── my-findings-inbox.blade.php + │ └── findings-intake-queue.blade.php + ├── governance/ + │ └── governance-inbox.blade.php + ├── monitoring/ + │ └── finding-exceptions-queue.blade.php + └── reviews/ + └── customer-review-workspace.blade.php +``` + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One additional derived family in `GovernanceInboxSectionBuilder` | the exception lane still sits outside the canonical decision home | leaving exceptions on a standalone specialist page keeps the current fragmented start state | +| One bounded convergence contract for arrival and return context | specialist pages need a truthful way back to the same governance scope | page-local ad hoc back links would drift across surfaces and duplicate navigation logic | + +## Proportionality Review + +- **Current operator problem**: operators still have to decide between several repo-real specialist surfaces before they can begin work. +- **Existing structure is insufficient because**: the current governance home does not yet own all high-signal lanes and the specialist pages do not clearly behave as secondary contexts. +- **Narrowest correct implementation**: extend the existing governance inbox and navigation continuity instead of adding a new shell or persisted workflow engine. +- **Ownership cost created**: maintain one more derived family, one bounded convergence helper, and focused tests. +- **Alternative intentionally rejected**: a new action-center page or persisted cross-family work queue was rejected as unnecessary structure for current-release truth. +- **Release truth**: current-release workflow compression. + +## Implementation Strategy + +### Suggested MVP Scope + +MVP = **User Story 1 + User Story 2 together**. The convergence slice only becomes meaningful once the governance home shows the missing lanes and the specialist surfaces preserve truthful return context. + +### Incremental Delivery + +1. Extend the governance inbox family assembly and page rendering. +2. Add convergence-aware arrival and return semantics on the specialist pages. +3. Tighten duplicate-truth prevention and calm secondary-context copy. +4. Finish with focused validation and formatting. + +### Team Strategy + +1. Settle the governance inbox family extension and navigation-context contract first. +2. Parallelize unit coverage for builder behavior and feature coverage for navigation continuity. +3. Serialize merges around the shared governance inbox and specialist page views so the decision-home language stays coherent. \ No newline at end of file diff --git a/specs/257-governance-decision-convergence/spec.md b/specs/257-governance-decision-convergence/spec.md new file mode 100644 index 00000000..34c639df --- /dev/null +++ b/specs/257-governance-decision-convergence/spec.md @@ -0,0 +1,320 @@ +# Feature Specification: Governance Decision Surface Convergence v1 + +**Feature Branch**: `257-governance-decision-convergence` +**Created**: 2026-04-29 +**Status**: Ready for implementation +**Input**: User description: "Prepare the roadmap-fit Decision-Based Operating Foundations slice as Governance Decision Surface Convergence: use the existing governance inbox as the canonical workspace decision home, converge the still-missing exception and customer-review decision lanes, and keep the work read-only, repo-grounded, and free of new workflow state." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has multiple repo-real decision surfaces such as `MyFindingsInbox`, `FindingsIntakeQueue`, `GovernanceInbox`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`, but operators still have to decide where to start by hopping between specialist pages. +- **Today's failure**: The product has a decision-first foundation, but it still lacks one clearly dominant workspace decision home that converges the open exception lane and the customer-review follow-up lane into the same calm operating model. +- **User-visible improvement**: An authorized workspace operator lands on one canonical governance home, sees the remaining high-signal lanes in one place, and can open a specialized surface with preserved context and a truthful return path instead of reconstructing the workflow manually. +- **Smallest enterprise-capable version**: Reuse the existing `/admin/governance/inbox` page as the canonical decision home, extend it with a derived `finding_exceptions` family from existing queue truth, make customer-review follow-up handoff explicit through existing review workspace surfaces, and align `My Findings`, `Findings intake`, `Finding exceptions`, and `Customer Review Workspace` behind shared arrival and return context. No new page shell, no new persistence, and no new mutation lane ship in this slice. +- **Explicit non-goals**: No new portal or dashboard shell, no new persisted inbox item or task state, no new assign/claim/approve/review mutations on the convergence surface, no cross-tenant compare or promotion work, no customer-facing portfolio board, no AI prioritization, and no generic workflow framework. +- **Permanent complexity imported**: One bounded family extension inside the existing governance inbox assembly path, one small convergence contract for arrival and return context across existing pages, and focused unit plus feature coverage. +- **Why now**: `docs/product/roadmap.md` still has an open `Decision-Based Operating Foundations` lane, while `docs/product/implementation-ledger.md` identifies decision-surface fragmentation as the highest unspecced operator workflow gap after already-specced compare, commercial, and cleanup packages are removed from the queue. +- **Why not local**: Extending only `FindingExceptionsQueue` or only `CustomerReviewWorkspace` would keep the current start-state ambiguity intact and would not establish a single truthful operator entry point. +- **Approval class**: Workflow Compression +- **Red flags triggered**: One multi-surface convergence flag and one derived-family-extension flag. Defense: the slice extends existing pages and builders, introduces no new persistence, and keeps all workflow state on the existing source surfaces. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - existing canonical workspace route `/admin/governance/inbox` + - existing `/admin/findings/my-work` + - existing `/admin/findings/intake` + - existing `/admin/finding-exceptions/queue` + - existing `/admin/reviews/workspace` + - existing tenant-scoped finding and review detail routes as drill-through targets only +- **Data Ownership**: + - `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only persisted truth for their respective families + - the convergence layer remains derived from the existing `GovernanceInbox` page and supporting builders; it introduces no new inbox-item table, cache, mirror entity, or workflow state + - no new review, exception, or decision summary persistence is introduced +- **RBAC**: + - workspace membership remains the first boundary for the canonical decision home and all converged launches + - non-members and explicit out-of-scope tenant filters remain `404` deny-as-not-found boundaries + - in-scope members who can access none of the converged families receive `403`, not a silent empty shell + - findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW` and keep claim or assignment semantics on their existing pages, including `Capabilities::TENANT_FINDINGS_ASSIGN` + - the exception lane reuses the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE`; this slice must not introduce a second exception capability family + - customer-review follow-up and review-pack handoff continue to reuse existing review and pack visibility checks; this slice must not introduce a new review-workspace capability family + - the convergence surface stays read-only; all mutations remain enforced on their existing source pages and actions + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped or tenant-prefiltered specialist page, the governance inbox prefilters to that tenant and, when relevant, to the originating family. Clearing filters returns to the workspace-wide decision home instead of preserving a local specialist scope forever. +- **Explicit entitlement checks preventing cross-tenant leakage**: Broad workspace listings silently omit inaccessible tenants and hidden families from counts, labels, and previews. Explicit tenant or record targets outside visible scope resolve as not found and do not leak family counts or presence hints. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation entry points, decision-home section summaries, action links, queue empty states, back-link continuity, and badge or status reuse +- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, `CustomerReviewWorkspace`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing action-surface declarations on those pages +- **Existing pattern(s) to extend**: the existing governance inbox route and section builder, tenant-prefilter state handling, canonical navigation context, calm empty-state copy on specialist pages, and existing read-first decision surfaces +- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, `ActionSurfaceDeclaration`, and existing source-page capability guards +- **Why the existing shared path is sufficient or insufficient**: the existing source pages already own the truth and the current governance inbox already owns the canonical route, but the current convergence is insufficient because finding exceptions and explicit customer-review decision continuity still sit outside the calm default operating model +- **Allowed deviation and why**: none. The slice should extend the existing governance inbox and source pages instead of introducing a second convergence shell or a generic task framework. +- **Consistency impact**: `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, `Open customer review workspace`, and `Back to governance inbox` language must stay consistent across section copy, specialist page affordances, and empty-state recovery actions. +- **Review focus**: reviewers must block any implementation that creates a new standalone convergence page, adds local specialist mutations to the governance home, or duplicates specialist proof content on the decision surface. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` start or completion behavior is introduced +- **Shared OperationRun UX contract/layer reused**: existing deep-link-only behavior on the already-present stale-operations family remains unchanged +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: the governance home still decides whether the existing stale-operations section is shown, but this spec does not widen or redefine that contract +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## 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`)* + +N/A - no new provider or platform-core boundary is widened. This slice only converges existing operator-facing decision surfaces. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Governance inbox page | yes | Native Filament page plus existing section builder | decision-home summaries, navigation entry points, badge reuse | page, URL-query, derived family state | no | Existing canonical route remains authoritative; no new shell is added | +| Findings and exception specialist queues (`MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`) | yes | Native Filament pages | arrival context, return continuity, calm secondary-context copy | page, URL-query, table/session filter restore | no | Remain specialized secondary-context surfaces; no workflow ownership moves here | +| Customer review workspace | yes | Native Filament page | review-follow-up handoff, return continuity, calm read-only context | page, URL-query, table/session filter restore | no | Remains the review-consumption surface, not a second governance home | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Governance inbox page | Primary Decision Surface | Operator decides which lane to open next across findings, exceptions, stale operations, alert failures, and review follow-up | visible family counts, top blockers, tenant scope, and one dominant next action per section | specialist queue details, review detail, alert detail, and finding detail after explicit open | Primary because it becomes the single workspace start surface instead of one of several competing starts | Aligns the product with the roadmap's decision-first operating direction | Reduces page hopping before the first action | +| Findings and exception specialist queues | Secondary Context | Operator acts inside the chosen lane after the first decision is already made | lane-specific list rows, current tenant scope, and one existing safe next action | record detail, due context, approval context, and deeper diagnostics on existing detail surfaces | Secondary because the lane is chosen at the governance home, not discovered here first | Keeps specialist work inside the existing pages without making them compete as starts | Removes the need to re-evaluate the whole workspace after every lane jump | +| Customer review workspace | Secondary Context | Operator verifies the latest customer-safe review state after a governance or review-follow-up cue | latest published review outcome, pack availability, and read-only summary | full review detail and pack download after explicit open | Secondary because it remains a read-only review-consumption surface entered from a governance cue | Preserves the customer-safe review workflow while fitting into the same decision hierarchy | Prevents the review workspace from becoming a competing attention home | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Governance inbox page | operator-MSP | family summary, tenant scope, top blockers, and section CTA | diagnostics stay on the specialist pages | raw payloads and debug detail stay on existing source pages only | `Open attention lane` | raw detail is never rendered on the decision home | the page states the current blocker once and sends the operator to the owning surface for proof | +| Findings and exception specialist queues | operator-MSP | lane-specific queue rows, due cues, status, and existing next action | specialist diagnostics and record detail remain available on existing routes | raw/support detail stays behind existing record detail affordances | existing lane-owned action only | any broad workspace summary stays off the specialist pages | specialist pages do not restate the workspace decision-home summary | +| Customer review workspace | operator-MSP, customer-read-only | latest customer-safe review status, pack availability, and read-only summary | deeper review diagnostics stay on review detail | raw JSON, provider payloads, and internal support evidence remain hidden or gated on existing detail/download paths | `Open latest review` | support-only raw evidence stays off the workspace surface | review workspace states review truth once and relies on review detail for proof | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the correct existing lane | explicit section CTA or preview-entry CTA | forbidden | section footer links and preview-entry links only | none | `/admin/governance/inbox` | existing specialist routes only | active workspace, optional tenant and family filter | Governance inbox | which lane needs attention now | none | +| Findings and exception specialist queues | List / Table / Read-only decision queue | Specialist queue | Open the owning record or use the existing inline safe shortcut | row click and existing specialist inspect path | required | existing lane-owned actions only | existing grouped destructive or risky actions remain on owning detail surfaces | `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/finding-exceptions/queue` | existing finding or exception detail routes | tenant filter, queue view, queue-specific states | Findings / Finding exceptions | lane-specific actionable truth only | none | +| Customer review workspace | List / Table / Read-only workspace report | Read-only review consumption | Open the latest published review | clickable row to latest review or explicit download/open action | required | existing read-only actions only | none | `/admin/reviews/workspace` | existing tenant review detail route | tenant filter and review availability | Customer review workspace | latest published review state and pack availability | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Workspace operator / MSP operator | Decide which governance lane to open next | Workspace decision hub | What needs attention now across my visible governance lanes? | family counts, top blockers, tenant scope, and section CTA | diagnostics remain on specialist pages | lane urgency, lane ownership, tenant scope | none | Open lane | none | +| Findings and exception specialist queues | Workspace operator / MSP operator | Work inside a chosen findings or exception lane | Specialist queue | What in this chosen lane needs action first? | queue rows, due or review cues, owner/assignee context, and current filter state | full record detail and deep diagnostics remain on existing detail routes | workflow status, due/overdue state, review or approval state | existing lane-owned mutations only | inspect record or existing lane action | existing queue-owned risky actions only | +| Customer review workspace | Workspace operator / readonly-capable tenant actor | Consume the latest review truth after a review-follow-up cue | Read-only review workspace | What is the latest published review state for this tenant? | latest review outcome, pack availability, and read-only summary | full review detail and pack detail after explicit open | review availability, review freshness, pack availability | none | Open latest review | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded convergence contract inside the existing governance inbox assembly and navigation-context seams +- **New enum/state/reason family?**: no new persisted family; any added family key remains derived and page-scoped +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: operators still start from multiple repo-real specialist surfaces even though the repo already has enough decision surfaces to support one calmer governance home +- **Existing structure is insufficient because**: the current governance inbox does not yet own all remaining high-signal lanes and the specialist pages do not clearly behave as secondary contexts +- **Narrowest correct implementation**: extend the existing governance inbox and current specialist pages with one more derived family and shared arrival/return semantics instead of creating a new shell or workflow engine +- **Ownership cost**: maintain one more derived section and a small navigation-context convergence layer plus focused tests +- **Alternative intentionally rejected**: a new global action center, persisted inbox-item table, and mutation-capable workflow engine were rejected as premature and structurally heavier than the current release truth requires +- **Release truth**: current-release workflow compression, not future-release platform speculation + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical extension of the existing governance inbox is preferred over adding a parallel decision surface. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves section assembly, family ordering, and convergence routing without Filament boot cost; focused feature coverage proves visibility, tenant-filter continuity, return context, and calm secondary-surface behavior on the existing pages +- **New or expanded test families**: focused `Unit/Support/GovernanceInbox` coverage plus focused `Feature/Governance`, `Feature/Monitoring`, `Feature/Reviews`, and `Feature/Findings` convergence coverage +- **Fixture / helper cost impact**: moderate; tests need workspace membership, visible and hidden tenants, findings, exceptions, review-follow-up states, and review-workspace fixtures, but should reuse existing factories and avoid browser or heavy-governance setup +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell +- **Standard-native relief or required special coverage**: special coverage is required for arrival/return context and duplicate-truth prevention across the specialist pages +- **Reviewer handoff**: reviewers must confirm that the governance inbox remains the single start surface, counts omit inaccessible families, specialist pages keep one dominant action, and no new mutation lane or persistence appears +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` + +## Scope Boundaries + +### In Scope + +- reuse the existing `GovernanceInbox` page as the canonical workspace decision home +- extend the governance inbox with a derived finding-exceptions family sourced from existing queue truth +- make customer-review follow-up and customer-review-workspace handoff explicit within the same decision hierarchy +- preserve tenant and family arrival context plus truthful return links between the governance home and `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` +- keep specialist pages as calm secondary-context surfaces with no duplicate workspace-level blocker summary + +### Non-Goals + +- creating a new global action-center page or dashboard shell +- replacing the existing specialist pages or moving their mutations to the governance home +- adding a new persisted inbox item, queue state, or workflow engine +- changing existing finding, exception, or review lifecycle semantics +- cross-tenant compare, promotion, or portfolio execution work +- customer-facing portfolio boards or AI-driven prioritization + +## Assumptions + +- the existing `GovernanceInboxSectionBuilder` can accept one more derived family without turning into a generic task engine +- current `CanonicalNavigationContext` and tenant-prefilter handling are sufficient to preserve truthful return paths between the decision home and specialist pages +- `CustomerReviewWorkspace` remains the correct read-only destination for customer-safe review consumption when a published review detail is not the better direct target + +## Risks + +- implementation could overreach and turn the governance home into a new task engine instead of a routing surface +- the finding-exceptions family could leak hidden tenant hints if capability and tenant scoping are not applied before counts and previews are derived +- specialist-page convergence could accidentally duplicate blocker language instead of keeping the decision summary on the governance home only + +## Follow-up Candidates + +- wider dashboard-entry convergence once the governance home proves adoption +- portfolio-level decision convergence with cross-tenant compare after Spec 043 implementation +- any mutation consolidation only after the read-only convergence hierarchy is proven and remains bounded + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Use one canonical governance home (Priority: P1) + +As a workspace operator, I want one governance home that includes the still-missing exception and review-consumption lanes so I can decide where to work next without choosing between multiple start pages first. + +**Why this priority**: This is the smallest slice that completes the roadmap's decision-first operating direction without inventing new workflow state. + +**Independent Test**: Seed visible findings, finding exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and section CTAs. + +**Acceptance Scenarios**: + +1. **Given** the actor can see findings, finding exceptions, and review follow-up for the current workspace, **When** they open the governance inbox, **Then** the page shows those lanes in one canonical surface with one dominant action per section. +2. **Given** the actor cannot see finding exceptions, **When** they open the governance inbox, **Then** the exception lane does not appear and no count or empty-state hint implies hidden work exists. +3. **Given** the actor applies a tenant-prefilter that hides all current rows, **When** they open the governance inbox, **Then** the page explains that the tenant filter is hiding other visible attention instead of falsely implying the whole workspace is calm. + +--- + +### User Story 2 - Move into a specialist lane and back without losing context (Priority: P1) + +As a workspace operator, I want to open a specialist queue or review workspace from the governance home and come back with the same tenant and family context so the governance home becomes my operating anchor instead of a one-off report. + +**Why this priority**: Convergence does not help if every lane jump loses the original decision context. + +**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope. + +**Acceptance Scenarios**: + +1. **Given** the actor opens `My Findings` or `Findings intake` from the governance inbox, **When** the specialist queue loads, **Then** the existing queue keeps its specialist semantics while exposing a truthful return path to the governance inbox. +2. **Given** the actor opens `Finding exceptions` from the governance inbox, **When** the queue loads, **Then** the queue preserves the arrival tenant context and does not become a competing workspace start surface. +3. **Given** the actor opens `Customer Review Workspace` from a review-follow-up cue, **When** they inspect the review lane, **Then** the page stays read-only and preserves the governance-home return path. + +--- + +### User Story 3 - Keep specialist surfaces calm and secondary (Priority: P2) + +As a workspace operator, I want specialist queues and the customer review workspace to keep their own lane truth without re-explaining the whole workspace blocker summary so each page stays focused on the action I already chose. + +**Why this priority**: Convergence should reduce attention load, not spread the same summary across more pages. + +**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps its lane-specific content while the workspace-level blocker summary remains on the governance home. + +**Acceptance Scenarios**: + +1. **Given** the actor opens a specialist queue from the governance home, **When** the specialist page renders, **Then** it shows only lane-specific actionable truth and not a duplicated workspace summary banner. +2. **Given** the actor opens the customer review workspace from a review-follow-up cue, **When** the page renders, **Then** it shows customer-safe review truth and not a second governance-home summary card. + +### Edge Cases + +- the actor can access the governance inbox but none of the converged specialist families +- the requested tenant filter is outside the actor's visible scope +- the same tenant has findings, exceptions, and review follow-up at once, but the governance home must still avoid duplicating the same blocker explanation across sections +- review follow-up exists for a tenant without a currently published review, requiring the fallback customer-review-workspace destination +- the selected tenant is calm for exceptions but not for other families, so the empty-state message must be truthful about what the filter is hiding + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-257-001 Canonical decision home**: The system MUST treat the existing `/admin/governance/inbox` page as the canonical workspace decision home for operator-facing governance attention. +- **FR-257-002 Exception-family convergence**: The governance inbox MUST derive a `finding_exceptions` family from existing `FindingExceptionsQueue` truth and render it without adding a new persisted inbox item or queue-state layer. +- **FR-257-003 Review-consumption handoff**: Review-follow-up cues on the governance inbox MUST route into the existing review-consumption surfaces, using the latest published review when available and the existing customer review workspace when it is the truthful fallback. +- **FR-257-004 Arrival and return continuity**: Launching `My Findings`, `Findings intake`, `Finding exceptions`, or `Customer Review Workspace` from the governance inbox MUST preserve truthful arrival and return context for the current tenant and family scope. +- **FR-257-005 Secondary-surface discipline**: Specialist queues and the customer review workspace MUST remain secondary-context surfaces and MUST NOT become competing workspace start surfaces through duplicated workspace summary banners or second primary CTAs. +- **FR-257-006 Visibility and omission semantics**: Family counts, section previews, and empty states MUST be derived only from tenants and families the actor can already see through existing capability and entitlement checks. +- **FR-257-007 Authorization semantics**: Non-members and out-of-scope tenant targets MUST resolve as `404`, while in-scope members who lack visibility to every converged family MUST receive `403`. +- **FR-257-008 No new workflow truth**: The slice MUST NOT add a new inbox-item table, a persisted convergence state, or a new cross-family mutation contract. +- **FR-257-009 Source-surface ownership**: Claim, assignment, approval, review, and pack-download behaviors MUST remain on their existing source surfaces and continue to enforce their existing capabilities there. +- **FR-257-010 Decision-first disclosure**: The governance inbox MUST show summary and next-action content only; raw payloads, review evidence, and specialist diagnostics MUST remain on the owning specialist or detail surfaces. +- **FR-257-011 Duplicate-truth prevention**: The governance inbox and the specialist surfaces MUST NOT restate the same workspace-level blocker or next-action summary as equal-priority content. +- **FR-257-012 Read-only review integrity**: The customer review workspace remains read-only in this slice and MUST NOT gain operator-only mutation controls through convergence work. + +### Non-Functional Requirements + +- **NFR-257-001**: The convergence layer remains DB-only and derived from existing persisted truth; it MUST NOT add Graph calls, remote calls, queues, or `OperationRun` creation. +- **NFR-257-002**: The slice MUST reuse existing Filament and shared UI primitives before any local UI framework or semantic layer is introduced. +- **NFR-257-003**: The feature MUST stay within focused `Unit` and `Feature` lanes only; browser or heavy-governance coverage is out of scope unless implementation proves a specific need. + +### UX Requirements + +- **UXR-257-001**: The governance inbox remains the one dominant start surface for the converged lanes. +- **UXR-257-002**: Each affected surface has exactly one dominant next action visible by default. +- **UXR-257-003**: Specialist surfaces keep lane-specific truth only and rely on explicit return links for workspace context. + +### RBAC / Security Requirements + +- **RBR-257-001**: The slice MUST reuse existing capability registries and MUST NOT introduce raw capability strings or role-name checks. +- **RBR-257-002**: Tenant-filter and family-filter state MUST NOT leak inaccessible tenant or family hints through counts, labels, or empty-state copy. + +### Auditability / Observability Requirements + +- **AOR-257-001**: The slice MUST NOT create a new page-view audit stream; existing audit ownership remains on the existing source-surface mutations and downloads. +- **AOR-257-002**: Any convergence-specific navigation or UI state remains derived and inspectable through tests rather than new runtime logging. + +### Data / Truth-Source Requirements + +- **DTR-257-001**: `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only source truth inputs for the decision home. +- **DTR-257-002**: Any added convergence family key remains derived page state, not persisted domain truth. + +## Out of Scope + +- new persistence or workflow-state layers +- new operator mutations on the governance home +- cross-tenant compare or promotion work +- customer-facing portfolio boards or customer portal changes +- AI prioritization or recommendation logic + +## Acceptance Criteria + +- the selected operator can open one canonical governance home that includes the missing exception lane and truthful review-consumption handoff without seeing a second competing start surface +- specialist pages preserve truthful arrival and return context when opened from the governance home +- hidden families and inaccessible tenants do not leak through counts, labels, or empty-state hints +- the customer review workspace remains read-only and customer-safe while participating in the same decision hierarchy +- no new persistence, workflow state, queue, or runtime mutation surface is introduced + +## Success Criteria + +- operators can explain one default start surface for governance work in the workspace +- the specialist pages feel like chosen lanes, not competing homes +- implementation can stay bounded to existing page and builder seams with no new framework layer + +## Open Questions + +- none diff --git a/specs/257-governance-decision-convergence/tasks.md b/specs/257-governance-decision-convergence/tasks.md new file mode 100644 index 00000000..f13816b3 --- /dev/null +++ b/specs/257-governance-decision-convergence/tasks.md @@ -0,0 +1,189 @@ +--- + +description: "Task list for Governance Decision Surface Convergence v1" + +--- + +# Tasks: Governance Decision Surface Convergence v1 + +**Input**: Design documents from `specs/257-governance-decision-convergence/` +**Prerequisites**: `specs/257-governance-decision-convergence/plan.md` (required), `specs/257-governance-decision-convergence/spec.md` (required) + +**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage for this read-only convergence slice. +**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Existing stale-operation links remain unchanged. +**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets return `404`; in-scope members with no visible family return `403`. Findings lanes reuse `Capabilities::TENANT_FINDINGS_VIEW`, existing inline safe actions keep their current capability checks such as `Capabilities::TENANT_FINDINGS_ASSIGN`, the exception lane reuses `Capabilities::FINDING_EXCEPTION_APPROVE`, and review handoff reuses existing review and pack visibility checks. +**Shared Pattern Reuse**: Reuse `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the existing specialist page action-surface contracts. No new shell, task engine, or persistence layer is allowed. +**Organization**: Tasks are grouped by user story so the governance-home extension, navigation convergence, and calm secondary-context rules remain independently testable after the shared groundwork is complete. + +## Test Governance Checklist + +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceInbox/`, `apps/platform/tests/Feature/Governance/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Monitoring/`, and `apps/platform/tests/Feature/Reviews/` families only. +- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or generic workflow fixtures. +- [x] Planned validation commands cover governance-home assembly, authorization, and arrival/return continuity without widening scope. +- [x] The declared surface test profile remains `global-context-shell` because arrival context and tenant-filter continuity are part of the contract. +- [x] Any broader action-center, dashboard-entry, or cross-tenant follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden implementation growth. +- [x] Test-governance outcome resolves as `keep` for this feature and does not widen the work into a heavier family. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded convergence slice, the existing governance-home seams, and the reviewer stop conditions before implementation begins. + +- [x] T001 Review the bounded convergence slice in `specs/257-governance-decision-convergence/spec.md` and `specs/257-governance-decision-convergence/plan.md` together with `docs/product/roadmap.md` and `docs/product/implementation-ledger.md`. +- [x] T002 [P] Confirm the current governance-home families and summary seams in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`. +- [x] T003 [P] Confirm the specialist-page arrival, return, and filter-state seams in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Extend the shared governance-home and navigation seams that every user story depends on. + +**Critical**: No user-story work should begin until this phase is complete. + +- [x] T004 [P] Define or extend the bounded family-aware arrival and return contract inside `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and any minimal supporting helper under `apps/platform/app/Support/GovernanceInbox/` without creating new persistence or a generic workflow framework. +- [x] T005 [P] Tighten family omission and access evaluation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` so inaccessible tenants and families disappear before counts are derived and in-scope no-family access resolves as `403`. +- [x] T006 Implement the derived `finding_exceptions` family in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` using existing `FindingExceptionsQueue` truth, current queue semantics, and existing capability rules. +- [x] T007 Implement truthful review-consumption handoff logic in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` so review-follow-up entries prefer existing latest review detail and fall back to `CustomerReviewWorkspace` only when that is the honest destination. +- [x] T008 [P] Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to keep one dominant CTA per section and avoid duplicate workspace-summary cards as the new family is added. + +**Checkpoint**: The governance home can derive the new family, family counts stay capability-safe, and navigation context rules are settled before story-specific work begins. + +--- + +## Phase 3: User Story 1 - Use One Canonical Governance Home (Priority: P1) + +**Goal**: Give the operator one governance home that includes the missing exception and review-consumption lanes without creating a new shell. + +**Independent Test**: Seed visible findings, exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and one dominant CTA per section. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Extend `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` to cover exception-family inclusion, family ordering, review-workspace fallback, and omission semantics for hidden tenants or families. +- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` to cover the visible exception lane, review-consumption handoff summary, tenant-filter empty-state truth, and one dominant CTA per section. +- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` to cover `404` vs `403` behavior when workspace access exists but all converged family visibility is removed. + +### Implementation for User Story 1 + +- [x] T012 [US1] Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the new convergence lane and family-aware summary or empty-state copy. +- [x] T013 [US1] Align governance-home copy in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to the stable vocabulary `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, and `Open customer review workspace`. + +**Checkpoint**: User Story 1 is independently functional when the governance inbox truthfully shows the missing lane and routes to the existing specialist destinations. + +--- + +## Phase 4: User Story 2 - Move Into A Specialist Lane And Back (Priority: P1) + +**Goal**: Preserve tenant and family context when the operator opens a specialist page from the governance home and returns. + +**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope. + +### Tests for User Story 2 + +- [x] T014 [P] [US2] Add or extend `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` for tenant and family arrival/return continuity across governance-home launches. +- [x] T015 [P] [US2] Add or extend `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` for governance-home arrival, preserved tenant context, and truthful `Back to governance inbox` continuity. +- [x] T016 [P] [US2] Add or extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for governance-home arrival and return continuity on review-follow-up launches, preferred latest-published-review destination when available, fallback to `CustomerReviewWorkspace` when not, preserved read-only state, and the absence of operator-only mutation controls. +- [x] T017 [P] [US2] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php` and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php` for governance-home launch and return continuity on the findings specialist pages. + +### Implementation for User Story 2 + +- [x] T018 [US2] Wire governance-home arrival and return context through `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`. +- [x] T019 [US2] Expose truthful return affordances without adding a second primary CTA. Repo truth: these native Filament pages expose the affordance through page header actions in `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`; no specialist Blade edits were required. + +**Checkpoint**: User Story 2 is independently functional when the operator can move between the governance home and the specialist pages without losing truthful context. + +--- + +## Phase 5: User Story 3 - Keep Specialist Surfaces Calm And Secondary (Priority: P2) + +**Goal**: Ensure the specialist pages stay focused on lane-specific truth and do not duplicate the workspace-level summary once convergence context exists. + +**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps lane-specific content while the workspace-level blocker summary remains on the governance home only. + +### Tests for User Story 3 + +- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to assert duplicate-truth prevention, one dominant default action on each specialist surface, and secondary-context copy when the pages are opened from the governance home. + +### Implementation for User Story 3 + +- [x] T021 [US3] Keep lane-specific summaries focused and avoid duplicating workspace-level blocker text. Repo truth: page classes now add secondary return context while existing specialist Blade views stay lane-focused; regression tests assert the governance-home summary text is absent from secondary pages. +- [x] T022 [US3] Align action-surface declarations, header affordances, and empty-state recovery actions across `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so the canonical start surface remains obvious. + +**Checkpoint**: User Story 3 is independently functional when specialist surfaces remain lane-specific secondary contexts instead of competing starts. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation and reviewer close-out without widening scope. + +- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`. +- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`. +- [x] T025 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files. +- [x] T026 [P] Confirm the slice introduced no new asset registration, no new globally searchable resource, and no new mutation lane; record any bounded follow-up for broader dashboard-entry or portfolio action-center work in the active implementation notes. +- [x] T027 [P] Confirm the slice introduced no new Graph or remote calls, no queue or `OperationRun` start path, and no page-view audit or runtime logging stream; record any bounded follow-up if implementation uncovers a structural need outside this slice. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical governance-home truth. +- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the governance home is not a dead-end report. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because specialist pages must already participate in the convergence flow. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and establishes the new canonical decision-home behavior. +- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the new home has truthful workflow continuity. +- **US3 (P2)**: independently testable after Phase 2 and refines the specialist pages once the convergence contract exists. + +### Within Each User Story + +- After the shared foundational contract work in Phase 2 is complete, write the listed Pest coverage first for each user story and make it fail for the intended gap. +- Land the shared builder and navigation contract before widening Blade or copy work. +- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story. + +--- + +## Parallel Execution Examples + +### User Story 1 + +- T009, T010, and T011 can run in parallel before runtime edits begin. +- After the family contract settles, T012 and T013 can proceed in parallel because rendering and copy alignment touch different seams. + +### User Story 2 + +- T014, T015, T016, and T017 can run in parallel because they cover different destinations in the convergence flow. +- After T018 settles the shared navigation contract, T019 can follow to align the Blade affordances. + +### User Story 3 + +- T020 can start before implementation finishes because it only captures the expected secondary-context behavior. +- T021 and T022 can proceed together once the shared convergence path is stable. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 together**. The slice becomes product-meaningful only when the governance home shows the missing lanes and the specialist pages preserve truthful return context. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and US2 together. +3. Add US3 secondary-context tightening. +4. Finish with focused validation and formatting in Phase 6. + +### Team Strategy + +1. Settle the governance-home family extension and navigation-context contract first. +2. Parallelize unit and feature coverage inside each story before runtime edits widen. +3. Serialize merges around the governance inbox and specialist Blade views so the decision-home language stays coherent. -- 2.45.2