Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
7f450f079c chore: migrate repo to managed spec-kit
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 53s
2026-04-23 00:22:55 +02:00
799 changed files with 7055 additions and 80853 deletions

View File

@ -1,4 +1,4 @@
[mcp_servers.laravel-boost]
command = "./scripts/platform-sail"
command = "vendor/bin/sail"
args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/wt-plattform"
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"

View File

@ -238,34 +238,6 @@ ## Active Technologies
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility)
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue)
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure (236-canonical-control-catalog-foundation)
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
- 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 (240-tenant-onboarding-readiness)
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
- 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)
@ -300,9 +272,10 @@ ## 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
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -1,625 +0,0 @@
---
name: platform-feature-finish
description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase.
---
# Skill: platform-feature-finish
## Purpose
Automate the TenantPilot platform feature completion workflow.
Trigger this skill when the user says something like:
- "alles committen pushen und PR gegen platform-dev"
- "feature fertig, bitte PR erstellen"
- "platform feature abschließen"
- "commit push PR mit Gitea MCP"
- "mach PR gegen platform-dev"
- "finish platform feature"
- "platform-dev nach dev vorbereiten"
- "platform-dev PR aktualisieren"
- "out-of-date mit dev beheben"
- "integration PR refresh"
- "platform-dev auf dev rebasen"
This skill handles:
1. Validate current Git branch
2. Commit all feature changes
3. Push current feature branch
4. Create a Gitea pull request into `platform-dev`
5. Refresh the `platform-dev``dev` integration PR when explicitly requested
6. Report the PR link and next integration step
---
## Branch Model
TenantPilot uses area branches:
```text
dev = shared integration branch
platform-dev = platform/application area integration branch
website-dev = website/marketing area integration branch
```
For platform features:
```text
platform-dev
feature branch
PR back to platform-dev
platform-dev → dev integration PR
```
Rules:
- Platform feature branches MUST target `platform-dev`.
- Do NOT target `dev` directly unless the user explicitly asks.
- Do NOT use `website-dev` for platform features.
- `platform-dev` is the default PR base for TenantPilot platform/application work.
- `dev` is the shared integration branch.
### Solo Workflow Rule
The user works alone on `platform-dev`.
For refreshing the integration branch before opening or updating the PR `platform-dev``dev`, prefer rebase over merge.
Do not repeatedly merge `origin/dev` into `platform-dev` for refresh.
Avoid creating repeated merge commits like:
```text
Merge remote-tracking branch 'origin/dev' into platform-dev
```
Use `--force-with-lease`, never plain `--force`.
If rebase conflicts occur, stop and report the conflict files.
---
## Preconditions
Before committing:
1. Confirm repository root.
2. Confirm current branch is not protected.
Protected branches:
```text
dev
platform-dev
website-dev
main
master
```
If the current branch is protected, STOP and report:
```text
Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken.
```
3. Confirm remote exists.
4. Confirm there are local changes, untracked files, or unpushed commits.
5. Confirm there are no unresolved conflicts.
Do not ask for confirmation unless:
- The current branch is protected.
- Git status indicates unresolved conflicts.
- There is no remote configured.
- `.env` or other local secret/config files would be committed.
- Commit fails.
- Push fails.
- Gitea MCP PR creation fails.
---
## Required Tools
Use terminal for Git operations.
Use Gitea MCP for pull request creation.
Preferred Gitea MCP operation:
```text
create_pull_request
```
Required PR parameters:
```json
{
"owner": "ahmido",
"repo": "TenantAtlas",
"head": "<current-feature-branch>",
"base": "platform-dev",
"title": "<generated-title>",
"body": "<generated-body>"
}
```
---
## Workflow
### Step 1 — Inspect Git state
Run:
```bash
git rev-parse --show-toplevel
git rev-parse --abbrev-ref HEAD
git status --porcelain
git status -sb
git config --get remote.origin.url
git log --oneline --max-count=5
```
Determine:
- repository root
- current branch
- changed files
- untracked files
- remote URL
- whether there are unpushed commits
- whether unresolved conflicts exist
If the current branch is protected, stop.
If unresolved conflicts exist, stop.
If no remote exists, stop.
---
### Step 2 — Check for local environment files
Before `git add -A`, check whether local environment/config files are modified or untracked:
```bash
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
```
If `.env` or another environment file is included, STOP and report:
```text
Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen.
```
Do not commit secrets or local runtime configuration.
---
### Step 3 — Build commit message
Use the current branch name.
If branch starts with a spec number, for example:
```text
256-external-support-desk-handoff
```
Generate:
```text
feat(specs/256): external support desk handoff
```
If branch does not contain a spec number, generate:
```text
feat(platform): complete <branch-name>
```
Rules:
- Use lowercase subject.
- Use feature-style subject.
- Do not include `WIP`.
- Do not include `final`.
- Do not include overly generic `updates`.
Examples:
```text
feat(specs/256): external support desk handoff
feat(specs/252): platform localization v1
feat(platform): improve tenant review workspace
```
---
### Step 4 — Commit all changes
Run:
```bash
git add -A
git commit -m "<commit-message>"
```
If there are no local changes to commit, continue only if the branch has unpushed commits.
Check unpushed commits with:
```bash
git status -sb
git log --oneline origin/<current-branch>..HEAD
```
If there are no local changes and no unpushed commits, report:
```text
Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit.
```
Then continue to PR creation only if the branch already exists remotely or can be pushed.
---
### Step 5 — Push branch
Run:
```bash
git push --set-upstream origin <current-branch>
```
If the upstream already exists, this is acceptable.
Never force-push unless the user explicitly requests it.
---
### Step 6 — Create PR into platform-dev via Gitea MCP
Use Gitea MCP to create a pull request:
```json
{
"owner": "ahmido",
"repo": "TenantAtlas",
"head": "<current-feature-branch>",
"base": "platform-dev",
"title": "<commit-message>",
"body": "Implements platform feature branch `<current-feature-branch>`.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev``dev`."
}
```
If a PR already exists for the same branch and base, do not create a duplicate.
Report the existing PR if available.
---
## Optional Step — Check platform-dev to dev PR
After creating the feature PR, check whether an open integration PR exists:
```text
platform-dev → dev
```
If a Gitea MCP list/search pull request function is available, use it.
If one exists, report:
```text
Der Folge-PR `platform-dev``dev` existiert bereits: <url>
```
If none exists, report:
```text
Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev``dev` erstellt oder aktualisiert werden.
```
Do not automatically create the `platform-dev``dev` PR unless the user explicitly asks for it.
Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet.
---
## Integration Refresh Mode
Use this mode when the user explicitly says one of the following:
- "platform-dev nach dev vorbereiten"
- "platform-dev PR aktualisieren"
- "out-of-date mit dev beheben"
- "integration PR refresh"
- "platform-dev auf dev rebasen"
- "auch platform-dev nach dev"
- "und danach platform-dev nach dev"
- "full integration"
- "kompletten platform-dev zu dev PR machen"
- "folge-pr erstellen"
This mode prepares or updates the integration PR:
```text
platform-dev → dev
```
Because the user works alone on `platform-dev`, prefer rebase over merge.
### Integration Refresh Preconditions
Before running this mode:
1. Ensure the working tree is clean.
2. Ensure there are no unresolved conflicts.
3. Fetch remote branches.
4. Ensure `origin/platform-dev` exists.
5. Ensure `origin/dev` exists.
If the working tree is dirty, STOP and report:
```text
Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird.
```
If unresolved conflicts exist, STOP and report the conflict files.
### Integration Refresh Workflow
Run:
```bash
git fetch origin
git checkout platform-dev
git reset --hard origin/platform-dev
git rebase origin/dev
git push --force-with-lease origin platform-dev
```
After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`:
```bash
git fetch origin
git merge-base --is-ancestor origin/dev origin/platform-dev \
&& echo "OK: platform-dev contains dev" \
|| echo "OUTDATED: platform-dev does not contain dev"
```
If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date.
Rules:
- Do not merge `origin/dev` into `platform-dev` for this refresh.
- Do not create repeated merge commits from `origin/dev` into `platform-dev`.
- Use `git push --force-with-lease origin platform-dev` after a successful rebase.
- Never use plain `git push --force`.
- If `git rebase origin/dev` reports conflicts, stop immediately.
- Do not continue to PR creation while a rebase is unresolved.
- Do not auto-merge the PR.
- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds.
If rebase conflicts occur, report:
```text
Rebase-Konflikte erkannt. Ich habe gestoppt.
Konfliktdateien:
<files>
Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen.
```
### Create or Report Integration PR
After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR:
```json
{
"owner": "ahmido",
"repo": "TenantAtlas",
"head": "platform-dev",
"base": "dev",
"title": "chore(platform): merge platform-dev into dev",
"body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically."
}
```
If an open PR already exists for `platform-dev``dev`, do not create a duplicate. Report the existing PR.
### Integration Refresh Reporting Format
Final response for this mode must include:
```text
Fertig.
- Branch aktualisiert: platform-dev
- Refresh-Methode: rebase auf origin/dev
- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev
- Push: --force-with-lease origin/platform-dev
- Integration PR: <url>
- Base: dev
- Hinweis: PR wurde nicht automatisch gemerged.
```
Do not claim tests passed unless they were actually executed.
---
## Reporting Format
Final response must be concise and include:
```text
Fertig.
- Branch: <branch>
- Commit: <commit-sha or "keine neuen Änderungen">
- Push: origin/<branch>
- PR: <url>
- Base: platform-dev
- Nächster Schritt: Nach Merge `platform-dev``dev` PR aktualisieren/erstellen
```
If tests were not run, say:
```text
Tests wurden in diesem Skill nicht automatisch ausgeführt.
```
Do not claim tests passed unless the tool actually ran them.
---
## Safety Rules
- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`.
- Never force-push unless explicitly requested.
- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`.
- Never auto-merge PRs unless explicitly requested.
- Never target `dev` directly for platform feature PRs unless explicitly requested.
- Never delete branches unless explicitly requested.
- Never claim tests were run unless the tool actually ran them.
- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes.
- If migrations were created, mention that the target environment needs migration execution after deployment.
- If unresolved conflicts exist, stop.
---
## Useful Commands
Inspect:
```bash
git rev-parse --show-toplevel
git rev-parse --abbrev-ref HEAD
git status --porcelain
git status -sb
git config --get remote.origin.url
```
Detect protected branch:
```bash
branch="$(git rev-parse --abbrev-ref HEAD)"
case "$branch" in
dev|platform-dev|website-dev|main|master)
echo "PROTECTED_BRANCH:$branch"
exit 2
;;
esac
```
Detect unresolved conflicts:
```bash
git diff --name-only --diff-filter=U
```
Detect `.env` changes:
```bash
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
```
Commit:
```bash
git add -A
git commit -m "<message>"
```
Push:
```bash
git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)"
```
Latest commit:
```bash
git rev-parse --short HEAD
git log -1 --pretty=%s
```
Integration refresh:
```bash
git fetch origin
git checkout platform-dev
git reset --hard origin/platform-dev
git rebase origin/dev
git push --force-with-lease origin platform-dev
```
Verify integration refresh:
```bash
git fetch origin
git merge-base --is-ancestor origin/dev origin/platform-dev \
&& echo "OK: platform-dev contains dev" \
|| echo "OUTDATED: platform-dev does not contain dev"
```
Check rebase conflicts:
```bash
git diff --name-only --diff-filter=U
```
---
## Example User Request
User:
```text
alles committen pushen und pr gegen platform-dev mit gitea mcp
```
Assistant should:
1. Check current branch.
2. Stop if branch is protected.
3. Stop if `.env` or secrets would be committed.
4. Commit all changes.
5. Push current branch.
6. Create PR into `platform-dev` with Gitea MCP.
7. Report result.
Do not ask unnecessary follow-up questions.
---
## Example Integration Refresh Request
User:
```text
platform-dev PR aktualisieren
```
Assistant should:
1. Ensure the working tree is clean.
2. Fetch origin.
3. Checkout `platform-dev`.
4. Reset local `platform-dev` to `origin/platform-dev`.
5. Rebase `platform-dev` onto `origin/dev`.
6. Push with `--force-with-lease`.
7. Verify `origin/dev` is an ancestor of `origin/platform-dev`.
8. Create or report the PR `platform-dev``dev`.
9. Report result.
Do not merge the PR automatically.

View File

@ -1,447 +0,0 @@
---
name: spec-kit-implementation-loop
description: Implement an existing TenantPilot/TenantAtlas Spec Kit feature, run tests, browser smoke checks where applicable, post-implementation analysis, fix all confirmed in-scope findings when safe and bounded, and repeat until no in-scope findings remain or a stop condition is reached.
---
# Skill: Spec Kit Implementation Loop
## Purpose
Use this skill to implement an already prepared TenantPilot/TenantAtlas Spec Kit feature and verify it with a bounded implementation loop.
This skill assumes `spec.md`, `plan.md`, and `tasks.md` already exist and have passed preparation readiness or have been explicitly accepted by the user.
The intended workflow is:
```text
active or explicitly named spec
→ inspect repo truth, constitution, spec, plan, tasks, and relevant code/tests
→ evaluate implementation gates
→ implement strictly task-by-task
→ run relevant tests/checks
→ run browser smoke test when UI/user-facing flows are affected
→ run strict post-implementation analysis
→ fix confirmed in-scope findings
→ repeat test + browser smoke + analysis + fix loop until clean or bounded stop condition is reached
→ final implementation report
```
## When to Use
Use this skill when the user asks to:
- implement an active or explicitly named Spec Kit feature
- run Spec Kit implement
- analyze after implementation
- fix implementation findings
- repeat implementation verification until no confirmed in-scope findings remain
- run tests and browser smoke checks after implementation
Typical user prompts:
```text
Implementiere die aktive Spec und analysiere danach, ob alles passt.
```
```text
Implementiere specs/243-product-usage-adoption-telemetry streng nach tasks.md.
```
```text
Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber.
```
```text
Implementiere die vorbereitete Spec. Danach Tests, Browser Smoke Test falls UI betroffen ist, Analyse und Fix-Loop bis keine In-Scope Findings mehr offen sind.
```
## Hard Rules
- Work strictly repo-based.
- Implement only the active or explicitly named Spec Kit feature.
- Do not choose a new candidate.
- Do not create a new spec.
- Do not expand scope beyond `spec.md`, `plan.md`, and `tasks.md`.
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
- Follow the repository constitution and existing Spec Kit conventions.
- Preserve TenantPilot/TenantAtlas terminology.
- Prefer small, reviewable patches over broad rewrites.
- Treat repository truth as authoritative over assumptions.
- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope.
- Fix only confirmed findings from tests, static checks, browser smoke checks, or post-implementation analysis.
- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded.
- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why.
- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence.
- Do not run destructive commands.
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets.
- Do not continue analysis/fix loops indefinitely.
- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated.
- Do not claim merge-readiness unless the Merge Readiness Gate passes.
## Required Inputs
The user should provide at least one of:
- explicit spec directory such as `specs/<number>-<slug>/`
- instruction to use the current active Spec Kit feature
- instruction to implement the prepared/current spec
If the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory.
## Required Repository Checks
Always check:
1. active Spec Kit context / current branch
2. git status
3. `.specify/memory/constitution.md`
4. the active spec directory
5. `spec.md`
6. `plan.md`
7. `tasks.md`
8. relevant templates or conventions under `.specify/templates/`
9. nearby existing specs with related terminology or scope
10. application code surfaces referenced by the active spec
11. existing tests related to the changed behavior
## Git and Branch Safety
Before making implementation changes:
1. Check the current branch.
2. Check whether the working tree is clean.
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
4. If the working tree only contains user-intended changes for this operation, continue cautiously.
5. Do not force checkout, reset, stash, rebase, merge, or delete branches.
6. Do not overwrite unrelated work.
## Quality Gates
### Gate 1: Spec Readiness Gate
Required before implementation starts.
Pass criteria:
- `spec.md`, `plan.md`, and `tasks.md` exist.
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
- The tasks are small, ordered, verifiable, and include test/validation tasks.
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
- No open question blocks safe implementation.
- The scope is small enough for a bounded implementation loop.
Fail behavior:
- Stop before implementation.
- Report readiness gaps.
- Do not compensate for an unclear spec by inventing implementation scope.
### Gate 2: Implementation Scope Gate
Required before changing application code.
Pass criteria:
- The active spec directory is known.
- The implementation target is traceable to specific tasks in `tasks.md`.
- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth.
- No required change would introduce unrelated product behavior.
- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics.
Fail behavior:
- Stop before code changes and report the conflict or ambiguity.
- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase.
### Gate 3: Test Gate
Required after implementation and after each fix iteration.
Pass criteria:
- Targeted tests for changed behavior pass.
- Relevant existing tests pass or failures are proven unrelated and documented.
- Static analysis, linting, formatting, or type checks used by the repository pass when applicable.
- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough.
- Regression coverage exists for each fixed Blocker or High finding where practical.
Fail behavior:
- Fix in-scope failures before post-implementation analysis.
- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec.
- Do not weaken tests to pass the gate.
### Gate 4: Browser Smoke Test Gate
Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
Not required for backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow.
Pass criteria:
- The relevant page or flow loads in a real browser or the repository's browser-testing harness.
- The primary action introduced or changed by the spec can be executed successfully.
- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant.
- Workspace/tenant context is preserved across the tested flow where relevant.
- RBAC/capability-dependent visibility behaves as expected where practical to verify.
- Livewire interactions complete without visible runtime errors.
- No relevant browser console errors occur.
- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented.
- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant.
- The smoke-tested path is documented in the final response.
Fail behavior:
- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness.
- If a browser issue is unrelated existing debt, document evidence and residual risk.
- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests.
- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that.
### Gate 5: Post-Implementation Analysis Gate
Required after implementation and after each fix iteration.
Pass criteria:
- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution.
- All completed tasks have implementation evidence.
- No confirmed in-scope findings remain.
- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe.
- Medium/Low findings that remain open are explicitly documented with one of these reasons:
- out of scope
- requires separate spec
- risky refactor
- existing unrelated debt
- not reproducible
- blocked by unclear product/architecture decision
- No scope expansion was introduced during fixes.
Fail behavior:
- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded.
- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice.
### Gate 6: Merge Readiness Gate
Required before claiming the implementation is ready for manual review/merge.
Pass criteria:
- Spec Readiness Gate passed.
- Implementation Scope Gate passed.
- Test Gate passed.
- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason.
- Post-Implementation Analysis Gate passed.
- `tasks.md` reflects actual completion status.
- No confirmed in-scope findings remain.
- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks.
- Final response includes changed files, tests/checks run, browser smoke result, iterations performed, residual risks, and follow-up candidates.
Fail behavior:
- Do not claim merge-readiness.
- Report the failed gate, remaining risks, and the smallest recommended next action.
## Implementation Loop
Execute the loop in bounded phases:
1. Evaluate the Spec Readiness Gate.
2. Evaluate the Implementation Scope Gate before changing application code.
3. Implement the active Spec Kit feature scope task-by-task.
4. Run targeted tests and relevant static/dynamic checks.
5. Evaluate the Test Gate.
6. Run a Browser Smoke Test when the change affects UI/user-facing flows.
7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason.
8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns.
9. Evaluate the Post-Implementation Analysis Gate.
10. Identify confirmed findings by severity: Blocker, High, Medium, Low.
11. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons.
13. Re-run relevant tests and browser smoke checks where applicable after fixes.
14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached.
15. Evaluate the Merge Readiness Gate.
16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt.
## Stop Conditions
Stop the implementation loop when any of the following is true:
- No confirmed in-scope findings remain.
- The same finding appears twice after attempted fixes.
- A required fix conflicts with the spec, plan, constitution, or repository architecture.
- A required fix would expand scope beyond the active spec.
- A required fix would require a risky unrelated refactor.
- A required fix depends on an unresolved product or architecture decision.
- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec.
- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec.
- Three analysis/fix iterations have already been completed.
- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics.
When stopping before full cleanliness, report exactly why the loop stopped and what remains.
## Post-Implementation Analysis Prompt
Use this prompt internally after implementation and after each fix iteration:
```markdown
Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer.
Analysiere die Implementierung der aktiven Spec streng repo-basiert.
Ziel:
Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist.
Prüfe gegen:
- spec.md
- plan.md
- tasks.md
- .specify/memory/constitution.md
- geänderte Anwendungscodes
- geänderte Tests
- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind
- bestehende Repository-Patterns
Wichtig:
- Keine Spekulation ohne Repo-Beleg.
- Keine Scope-Erweiterung.
- Keine neuen Produktideen als Pflicht-Fixes.
- Findings nach Blocker, High, Medium, Low gruppieren.
- Für jedes Finding konkrete Datei-/Code-Belege nennen.
- Für jedes Finding eine minimale Remediation nennen.
- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen.
- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind.
- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert.
- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind.
- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben.
```
## Task Completion Rules
- Keep `tasks.md` aligned with actual implementation status.
- Check off tasks only after the implementation and test evidence exists.
- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it.
- If a task cannot be completed inside scope, leave it unchecked and report why.
## Testing Rules
- Add or update tests for all changed business behavior.
- Include RBAC and workspace/tenant isolation tests where relevant.
- Include OperationRun, audit, evidence, or result-truth tests where relevant.
- Prefer regression tests for every fixed Blocker or High finding.
- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn.
- Do not weaken tests to pass the suite.
- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant.
## Browser Smoke Test Rules
Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested.
Minimum smoke path:
1. Open the relevant page or entry point.
2. Confirm the expected workspace/tenant context where relevant.
3. Confirm the changed or newly introduced UI element is visible.
4. Execute the primary action or interaction changed by the spec.
5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown.
6. Check for relevant console errors.
7. Check for failed network requests related to the tested flow.
8. Document the tested path in the final response.
For TenantPilot/TenantAtlas, pay special attention to:
- Filament actions and header actions
- Livewire polling, modals, validation, and actions
- workspace/tenant context preservation
- RBAC/capability-dependent action visibility
- OperationRun links and drilldown continuity
- audit/evidence/result/support-diagnostic drilldowns where relevant
- empty states, badges, labels, and decision guidance where relevant
Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes.
Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification.
## Failure Handling
If an implementation step, test phase, browser smoke phase, or post-implementation analysis fails:
1. Stop at the relevant gate or stop condition.
2. Report the failing command or phase.
3. Summarize the error.
4. Do not attempt unrelated implementation as a workaround.
5. Suggest the smallest safe next action.
If the branch or working tree state is unsafe:
1. Stop before implementation changes.
2. Report the current branch and relevant uncommitted files.
3. Ask the user to commit, stash, or move to a clean worktree.
## Final Response Requirements
Respond with:
1. Active spec directory
2. Summary of implemented changes
3. Tests/checks run and their results
4. Browser smoke test result, tested path, or not-applicable reason
5. Quality gates passed/failed and number of analysis/fix iterations performed
6. Remaining in-scope findings, if any
7. Residual risks and follow-up candidates, if relevant
8. Files changed
9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge
Keep the final response concise, but include enough detail for the user to continue immediately.
## Manual Review Prompt
Provide a ready-to-copy prompt like this, adapted to the active spec number and slug:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Führe eine finale manuelle Review der implementierten Spec `<spec-number>-<slug>` streng repo-basiert durch.
Ziel:
Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md.
- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant.
- Benenne nur konkrete Findings mit Repo-Beleg.
- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-implementation-loop.
Implementiere die aktive Spec.
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
```
Expected behavior:
1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests.
2. Evaluate the Spec Readiness Gate and Implementation Scope Gate.
3. Implement only the active spec scope.
4. Run targeted tests and relevant checks.
5. Evaluate the Test Gate.
6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected.
7. Run post-implementation analysis.
8. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions.
10. Evaluate the Merge Readiness Gate.
11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt.
```

View File

@ -1,562 +0,0 @@
---
name: spec-kit-next-best-prep
description: Select the next suitable TenantPilot/TenantAtlas spec candidate from roadmap/spec-candidates, run the repository's Spec Kit preparation flow, create or update spec.md/plan.md/tasks.md, run preparation analysis, fix preparation-artifact issues only, and stop before application implementation.
---
# Skill: Spec Kit Next-Best Preparation
## Purpose
Use this skill to prepare the next implementation-ready Spec Kit package for TenantPilot/TenantAtlas without implementing application code.
This skill supports preparation only:
1. Select or scope the next suitable feature from roadmap/spec-candidates.
2. Run the repository's real Spec Kit preparation workflow where available.
3. Create or update `spec.md`, `plan.md`, and `tasks.md`.
4. Run preparation `analyze` when supported.
5. Fix preparation-artifact issues only.
6. Evaluate preparation quality gates.
7. Stop before application implementation.
The intended workflow is:
```text
roadmap / spec-candidates / feature idea
→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code
→ select the next suitable candidate or scope the provided idea
→ run Spec Kit specify/plan/tasks/analyze where available
→ create or update spec.md + plan.md + tasks.md
→ fix preparation-artifact issues only
→ evaluate Candidate Selection Gate and Spec Readiness Gate
→ final preparation report
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks to:
- select the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources
- turn a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md`
- prepare Spec Kit artifacts in one pass
- run specify/plan/tasks/analyze without implementation
- fix preparation analysis issues in Spec Kit artifacts only
- prepare a feature package for a later implementation skill
Typical user prompts:
```text
Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks.
```
```text
Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren.
```
```text
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und führe specify, plan, tasks und analyze aus.
```
```text
Behebe alle analyze-Issues in den Spec-Kit-Artefakten. Keine Application-Implementierung.
```
## Hard Rules
- Work strictly repo-based.
- This is a preparation-only skill.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, routes, views, tests, or runtime behavior.
- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available.
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
- Do not bypass Spec Kit branch mechanics.
- Create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts.
- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`.
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
- Follow the repository constitution and existing Spec Kit conventions.
- Preserve TenantPilot/TenantAtlas terminology.
- Prefer small, reviewable, implementation-ready specs over broad rewrites.
- Treat repository truth as authoritative over assumptions.
- If repository truth conflicts with the user-provided draft or candidate wording, keep repository truth and document the deviation.
- Fix only confirmed preparation-artifact findings from Spec Kit preparation analysis.
- Do not leave preparation findings open silently. If they are not fixed, document exactly why.
- Do not run destructive commands.
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
- Do not overwrite existing specs.
- Do not move from preparation to an implementation step inside this skill.
## Required Inputs
The user should provide at least one of:
- feature title and short goal
- full spec candidate
- roadmap item
- rough problem statement
- UX or architecture improvement idea
- instruction to choose the next best candidate from roadmap/spec-candidates
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions.
If no suitable candidate can be selected safely, stop and report why.
## Required Repository Checks
Always check:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `.specify/scripts/`
4. existing Spec Kit command usage or repository instructions, if present
5. current branch and git status
6. `specs/`
7. `docs/product/spec-candidates.md`
8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present
9. nearby existing specs with related terminology or scope
10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates
Do not edit application code.
## Git and Branch Safety
Before running any Spec Kit command:
1. Check the current branch.
2. Check whether the working tree is clean.
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
7. Do not overwrite existing specs.
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
## Quality Gates
### Gate 1: Candidate Selection Gate
Required before creating a new spec from roadmap/spec-candidates.
Pass criteria:
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
- The selected candidate is not already covered by an existing active or completed spec.
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
Fail behavior:
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
- Do not invent a new roadmap direction to force progress.
### Gate 2: Spec Readiness Gate
Required before reporting that the package is ready for implementation.
Pass criteria:
- `spec.md`, `plan.md`, and `tasks.md` exist.
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
- The tasks are small, ordered, verifiable, and include test/validation tasks.
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
- No open question blocks safe implementation.
- The scope is small enough for a bounded implementation loop in a later implementation skill.
- Required checklist artifacts exist when the constitution requires them.
Fail behavior:
- Fix preparation-artifact issues when they are safe and bounded.
- If readiness cannot be achieved without implementation or unresolved product decisions, stop and report the gap.
- Do not compensate for an unclear spec by inventing implementation scope.
## Candidate Selection Rules
When the user asks for the next best spec from roadmap/spec-candidates:
- Read `docs/product/spec-candidates.md`.
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
- Check existing specs to avoid duplicates.
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
- Prefer small, implementation-ready slices over broad platform rewrites.
- If multiple candidates are plausible, choose one primary candidate and document why it was selected.
- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope.
- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one.
- Do not pick a spec only because it is listed first.
- Evaluate the Candidate Selection Gate before creating the spec directory.
Evaluate candidates using these criteria:
1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer?
2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns?
3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent?
4. **Scope Size**: Can it be implemented as a narrow, testable slice?
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
6. **Risk Reduction**: Does it reduce current architectural or product risk?
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
## Required Selection Output Before Spec Kit Execution
Before running the Spec Kit flow, identify:
- selected candidate title
- source location in roadmap/spec-candidates
- why it was selected
- why close alternatives were deferred
- roadmap relationship
- smallest viable implementation slice
- proposed concise feature description to feed into `specify`
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
## Spec Kit Preparation Flow
### Step 1: Determine the repository's Spec Kit command pattern
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
Common locations to inspect:
```text
.specify/scripts/
.specify/templates/
.specify/memory/constitution.md
.github/prompts/
.github/skills/
README.md
specs/
```
Use the repo-specific mechanism if present.
### Step 2: Run `specify`
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
The `specify` input should include:
- selected candidate title
- problem statement
- operator/user value
- roadmap relationship
- out-of-scope boundaries
- key acceptance criteria
- important enterprise constraints
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
### Step 3: Run `plan`
Run the repository's `plan` flow for the generated spec.
The `plan` input should keep the scope tight and should require repo-based alignment with:
- constitution
- existing architecture
- workspace/tenant isolation
- RBAC
- OperationRun/observability where relevant
- evidence/snapshot/truth semantics where relevant
- Filament/Livewire conventions where relevant
- test strategy
### Step 4: Run `tasks`
Run the repository's `tasks` flow for the generated plan.
The generated tasks must be:
- ordered
- small
- testable
- grouped by phase
- limited to the selected scope
- suitable for later implementation or manual analysis before implementation
### Step 5: Run preparation `analyze`
Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it.
Analyze must check:
- consistency between `spec.md`, `plan.md`, and `tasks.md`
- constitution alignment
- roadmap alignment
- whether the selected candidate was narrowed safely
- whether tasks are complete enough for implementation
- whether tasks accidentally require scope not described in the spec
- whether plan details conflict with repository architecture or terminology
- whether implementation risks are documented instead of silently ignored
Do not use analyze as a trigger to implement application code.
### Step 6: Fix preparation-artifact issues only
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
- `spec.md`
- `plan.md`
- `tasks.md`
- `checklists/requirements.md` or other generated Spec Kit metadata files, if the repository uses them
Allowed fixes include:
- clarify requirements
- tighten scope
- move out-of-scope work into follow-up candidates
- correct terminology
- add missing tasks
- remove tasks not backed by the spec
- align plan language with repository architecture
- add missing acceptance criteria or validation tasks
- add missing checklist artifacts required by the constitution
Forbidden fixes include:
- modifying application code
- creating migrations
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
- running implementation or test-fix loops
- changing runtime behavior
### Step 7: Evaluate the Spec Readiness Gate
After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate.
Stop after this gate and do not implement.
## Spec Directory Rules
When creating a new spec directory, use the repository's Spec Kit-generated directory or path.
If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug:
```text
specs/<number>-<slug>/
```
The exact number must be derived from the current repository state and existing numbering conventions.
Create or update preparation artifacts inside the selected spec directory:
```text
specs/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/tasks.md
```
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions.
## `spec.md` Requirements
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
Include:
- Feature title
- Problem statement
- Business/product value
- Primary users/operators
- User stories
- Functional requirements
- Non-functional requirements
- UX requirements
- RBAC/security requirements
- Auditability/observability requirements
- Data/truth-source requirements where relevant
- Out of scope
- Acceptance criteria
- Success criteria
- Risks
- Assumptions
- Open questions
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
- workspace/tenant isolation
- capability-first RBAC
- auditability
- operation/result truth separation
- source-of-truth clarity
- calm enterprise operator UX
- progressive disclosure where useful
- no false positive calmness
## `plan.md` Requirements
The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself.
Include:
- Technical approach
- Existing repository surfaces likely affected
- Domain/model implications
- UI/Filament implications
- Livewire implications where relevant
- OperationRun/monitoring implications where relevant
- RBAC/policy implications
- Audit/logging/evidence implications where relevant
- Data/migration implications where relevant
- Test strategy
- Rollout considerations
- Risk controls
- Implementation phases
The plan should clearly distinguish where relevant:
- execution truth
- artifact truth
- backup/snapshot truth
- recovery/evidence truth
- operator next action
## `tasks.md` Requirements
Tasks must be ordered, small, and verifiable.
Include:
- checkbox tasks
- phase grouping
- tests before or alongside implementation tasks where practical
- final validation tasks
- documentation/update tasks if needed
- explicit non-goals where useful
Avoid vague tasks such as:
```text
Clean up code
Refactor UI
Improve performance
Make it enterprise-ready
```
Prefer concrete tasks such as:
```text
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
## Preparation Scope Control
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
Examples of follow-up candidates:
- assigned findings
- pending approvals
- personal work queue
- notification delivery settings
- evidence pack export hardening
- operation monitoring refinements
- autonomous governance decision surfaces
Do not force all follow-up candidates into the primary spec.
## Failure Handling
If a Spec Kit command or preparation analyze phase fails:
1. Stop at the relevant gate.
2. Report the failing command or phase.
3. Summarize the error.
4. Do not attempt implementation as a workaround.
5. Suggest the smallest safe next action.
If the branch or working tree state is unsafe:
1. Stop before running Spec Kit commands.
2. Report the current branch and relevant uncommitted files.
3. Ask the user to commit, stash, or move to a clean worktree.
## Final Response Requirements
Respond with:
1. Selected candidate and why it was chosen
2. Why close alternatives were deferred
3. Current branch after Spec Kit execution, if changed
4. Generated spec path
5. Files created or updated by Spec Kit
6. Preparation analyze result summary
7. Preparation-artifact fixes applied after analyze
8. Assumptions made
9. Open questions, if any
10. Candidate Selection Gate result
11. Spec Readiness Gate result
12. Recommended next implementation prompt
13. Explicit statement that no application implementation was performed
Keep the final response concise, but include enough detail for the user to continue immediately.
## Manual Review and Next-Step Prompts
Provide a ready-to-copy manual artifact review prompt like this, adapted to the generated spec branch/path:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Analysiere die neu erstellte Spec `<spec-branch-or-spec-path>` streng repo-basiert.
Ziel:
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe nur gegen Repo-Wahrheit.
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
```
Also provide a ready-to-copy implementation prompt for the separate implementation skill after analyze has passed or preparation-artifact issues have been fixed:
```markdown
/spec-kit-implementation-loop
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-next-best-prep.
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
Keine Application-Implementierung.
```
Expected behavior:
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
2. Check branch and working tree safety.
3. Compare candidate suitability.
4. Select the next best candidate.
5. Evaluate the Candidate Selection Gate.
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
7. Run the repository's real Spec Kit `plan` flow.
8. Run the repository's real Spec Kit `tasks` flow.
9. Run the repository's real Spec Kit preparation `analyze` flow.
10. Fix analyze issues only in Spec Kit preparation artifacts.
11. Evaluate the Spec Readiness Gate.
12. Stop before application implementation.
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
```

View File

@ -1,34 +1,32 @@
<!--
Sync Impact Report
- Version change: 2.10.0 -> 2.11.0
- Modified principles:
- Expanded decision-first and operator-surface rules so operational,
governance, evidence, onboarding, review, and support-facing
detail/status surfaces separate decision content, operator
diagnostics, and support/raw evidence
- Expanded review and enforcement expectations so specs, plans,
tasks, and checklists must make audience modes, raw/support
gating, one dominant next action, and duplicate-truth prevention
explicit
- Version change: 2.7.0 -> 2.8.0
- Modified principles: None
- Added sections:
- Audience-Aware Decision Surfaces & Disclosure Ladder
(DECIDE-AUD-001): requires customer-readable default paths,
operator diagnostics as progressive disclosure, support/raw
evidence gating, one dominant next action, and no duplicate truth
across equal-priority cards
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
migration shims, dual-write logic, and compatibility fixtures in a
pre-production codebase; includes AI-agent verification checklist,
review rule, and explicit exit condition at first production deploy
- Shared Pattern First For Cross-Cutting Interaction Classes
(XCUT-001): requires shared contracts/presenters/builders for
notifications, status messaging, action links, dashboard signals,
navigation, and similar interaction classes before any local
domain-specific variant is allowed
- Removed sections: None
- Templates requiring updates:
- .specify/templates/spec-template.md: add audience-aware disclosure
section + constitution prompts ✅
- .specify/templates/plan-template.md: add audience/disclosure
planning prompts + constitution checks ✅
- .specify/templates/tasks-template.md: add decision/disclosure
implementation + test tasks ✅
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
one-primary-action, and duplicate-truth review checks ✅
- docs/product/standards/README.md: refresh constitution index for
the new audience-aware disclosure contract ✅
- .specify/templates/spec-template.md: added "Compatibility posture"
default block ✅
- .specify/templates/spec-template.md: add cross-cutting shared-pattern
reuse block ✅
- .specify/templates/plan-template.md: add shared pattern and system
fit section ✅
- .specify/templates/tasks-template.md: add cross-cutting reuse task
requirements ✅
- .specify/templates/checklist-template.md: add shared-pattern reuse
review checks ✅
- .github/agents/copilot-instructions.md: added "Pre-production
compatibility check" agent checklist ✅
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None
@ -68,15 +66,6 @@ ### No Premature Abstraction (ABSTR-001)
- Test convenience alone is not sufficient justification for a new abstraction.
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
### First Provider Is Not Platform Core (PROV-001)
- Microsoft is the current first provider, not the platform core.
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
@ -313,57 +302,24 @@ ### Operations / Run Observability Standard
even if implemented by multiple jobs/steps (“umbrella run”).
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
- Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only:
authorize, create/reuse run (dedupe), enqueue work, and hand queued/start-state feedback to the shared
OperationRun Start UX Contract.
### OperationRun Start UX Contract (OPS-UX-START-001)
- OperationRun UX MUST be contract-driven, not surface-driven.
- Any feature that creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun` MUST use
the central OperationRun Start UX Contract.
- Filament Pages, Resources, Widgets, Livewire Components, Actions, and Services MUST NOT independently compose
OperationRun start UX from local pieces.
- The shared OperationRun UX layer MUST own:
- local start notification / toast
- `Open operation` / `View run` link
- artifact link such as `View snapshot`, `View pack`, or `View restore`
- run-enqueued browser event
- queued DB-notification decision
- dedupe / already-available / already-running messaging
- blocked / failed-to-start messaging
- tenant/workspace-safe operation URL resolution
- Feature surfaces MAY initiate `OperationRun`s, but they MUST NOT define their own OperationRun UX semantics.
- `OperationRun` lifecycle state remains the canonical execution truth.
- Queued DB notifications MUST remain explicit opt-in unless the active spec defines a different policy.
- Terminal `OperationRun` notifications MUST be emitted through the central OperationRun lifecycle mechanism.
- Any exception MUST include:
1. an explicit spec decision,
2. a documented architecture note,
3. a test or guard-test exception with rationale,
4. a follow-up migration decision if the exception is temporary.
- New OperationRun-starting features MUST include an `OperationRun UX Impact` section in the active spec or plan.
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
confirm + “View run”.
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
If a feature creates/reuses `OperationRun`, its default feedback contract is exactly three surfaces.
Queued DB notifications are forbidden by default and MAY exist only when the active spec explicitly opts into them
through the OperationRun Start UX Contract:
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
- Queued toast copy, action links, artifact links, start-state browser events, and dedupe/start-failure messaging MUST be
produced by the shared OperationRun Start UX Contract, not by local surface code.
2) Progress (active awareness only)
- Live progress MUST exist only in:
- the global active-ops widget, and
- Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Running DB notifications are forbidden.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
@ -404,10 +360,6 @@ ### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- feature code bypasses the central OperationRun Start UX Contract for queued/start-state operation UX where the repo's
guardable patterns can detect it,
- feature code emits queued DB notifications for operations without explicit spec-driven opt-in through the shared
OperationRun UX layer,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again.
@ -593,114 +545,11 @@ ##### Review gate
5. Is this a Primary Decision Surface, Secondary Context Surface, or
Tertiary Evidence / Diagnostics Surface?
6. If it is primary, why can it not live inside an existing decision
context?
context?
7. Does the navigation reflect a workflow or only storage structure?
8. Does this reduce search, review, or click work?
9. Does this make the product calmer and clearer instead of louder?
#### Audience-Aware Decision Surfaces & Disclosure Ladder (DECIDE-AUD-001)
Goal: every operational, governance, evidence, onboarding, review, and
support-facing detail or status surface MUST keep customer-readable
decision content, operator diagnostics, and support/raw evidence
intentionally separated while preserving full depth through progressive
disclosure.
##### Audience ladder is explicit
- In-scope detail and status surfaces MUST define their content using
this three-tier hierarchy when applicable:
- decision content
- operator diagnostics
- support / raw evidence
- Surfaces that are reachable by more than one audience class MUST
define their default-visible content for at least these layers when
applicable:
- customer / read-only default
- operator / MSP diagnostics
- platform / support raw evidence
- The surface contract MUST state which capabilities unlock each deeper
layer.
- Support/raw evidence MUST NOT become the default first-read
experience on customer-readable or ordinary operator-facing
surfaces.
##### Customer-readable default path
- The default reading path for customer/read-only users MUST optimize
for status, reason, impact, one dominant next action, and a short
result or artifact summary.
- Internal lifecycle wording, debug semantics, implementation field
names, raw payload fragments, and support-oriented context MUST NOT
appear in the default customer-readable path unless they are the only
way to understand the first decision.
- Default-visible customer/read-only content is responsible for status,
reason, impact, the dominant next action, and a concise supporting
summary only.
##### Diagnostics are secondary by default
- Diagnostics such as lifecycle, timings, verification detail, drift
detail, permission detail, provider summaries, or related-operation
context MUST be lower-priority than the decision surface and MUST be
collapsed, tabbed, grouped, or otherwise progressively disclosed when
the first decision does not require them.
- Authorized operators MAY expand diagnostics, but diagnostics MUST NOT
visually compete with the primary decision block.
- Where no support/raw tier is exposed, diagnostics still remain below
the decision tier and MUST NOT restate the same decision summary at
equal weight.
##### Raw/support evidence is gated
- Raw/support evidence such as JSON, raw context payloads,
fingerprints, internal reason ownership, platform reason families,
monitoring detail, viewer context, or copy/show-raw actions MUST NOT
appear in the default decision path.
- These details MUST live behind explicit reveal affordances and MUST
be capability-gated wherever the audience model distinguishes support
or platform users from ordinary operators.
- Capability-gated support/raw disclosure MUST fail closed when the
actor lacks the required scope or capability.
##### One dominant next action
- A decision surface MUST expose exactly one dominant next action in
the default-visible region.
- Optional secondary actions MAY exist, but they MUST NOT compete with
the primary remediation or decision action in prominence.
- Contextual navigation such as opening a related run, tenant, report,
or technical detail remains secondary.
##### No duplicate truth across equal-priority cards
- The same blocker, reason, or next action MUST NOT be repeated across
multiple equal-priority cards, sections, or summary blocks on the
same default-visible surface.
- Supporting evidence MAY restate the underlying proof, but the
dominant decision message appears once and diagnostics elaborate
beneath it.
##### Required tests
- New or materially changed customer/operator-facing detail surfaces
MUST include focused tests proving:
- default-visible content shows status, reason, impact, and next
action,
- exactly one dominant next action is primary,
- diagnostics are secondary or collapsed,
- raw/support evidence is not default-visible,
- support/raw sections are capability-gated where applicable,
- and duplicate visible decision summaries are absent.
##### Stored evidence wins over fallback diagnostics
- When a stored verification or report artifact exists, fallback
technical diagnostics SHOULD demote behind supporting evidence or
technical details instead of remaining peer-level default content.
- Fallback diagnostics MAY become temporarily prominent only when the
higher-level artifact does not yet exist or is unavailable.
#### Surface Taxonomy (UI-SURF-001)
Every new admin surface MUST be assigned exactly one broad action-surface
@ -1424,22 +1273,11 @@ #### Operator Surface Principles (OPSURF-001)
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
- Detail/status surfaces MUST satisfy DECIDE-AUD-001: decision content
first, operator diagnostics second, support/raw evidence third.
Distinct truth dimensions
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
Dominant next action and duplicate-truth control
- Default-visible decision content MUST include status, reason,
impact, and one dominant next action where those concepts exist.
- Secondary navigation or debug helpers MUST remain lower-priority
than the dominant decision action.
- The same blocker, reason, impact, or next action MUST NOT be
repeated across multiple default-visible cards, sections, tabs, or
summaries.
Explicit mutation scope
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
@ -1460,13 +1298,6 @@ #### Operator Surface Principles (OPSURF-001)
Page contract requirement
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
- The page contract MUST live in the governing spec and stay in sync with implementation.
- Where multiple audience classes share the page, the contract MUST
explicitly define the customer/read-only default path, operator
diagnostics path, support/raw-evidence path, and the capabilities
that unlock each layer.
- The page contract MUST also make the dominant next action,
duplicate-truth prevention, and raw/support gating explicit for
changed detail/status surfaces.
#### Spec Scope Fields (SCOPE-002)
@ -1491,11 +1322,8 @@ #### Enforcement Model (UI-REVIEW-001)
native, custom, or a shared detail family, what shared core vs host
variation exists if relevant, which layer owns the relevant shell,
page, and detail truth, which requested/active/draft/inspect/
restorable roles exist, which audience ladder and disclosure
boundaries exist, what the dominant next action is, how raw/support
evidence is gated, how duplicate truth is prevented, whether any
fake-native or host-drift risk is present, and whether an exception
type is used.
restorable roles exist, whether any fake-native or host-drift risk is
present, and whether an exception type is used.
- Missing any of those answers makes the spec incomplete.
PR review requirements
@ -1510,12 +1338,7 @@ #### Enforcement Model (UI-REVIEW-001)
promoted into primary navigation without justification, one case
fragmented across multiple equal-rank pages, new automation that adds
attention surfaces without reducing operator work, noisy default
surfaces with no action/watch/reference hierarchy, duplicate visible
blocker/reason/next-action summaries, customer/operator default paths
that expose raw JSON, fingerprints, reason ownership, platform reason
families, or monitoring detail, helper actions such as `Open
operation`, `Technical details`, or `Show JSON` competing with the
dominant decision action, `Filament Costume`,
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
undocumented exceptions without dedicated tests.
@ -1527,15 +1350,11 @@ #### Enforcement Model (UI-REVIEW-001)
presence of explicit Inspect on Queue / Review and History / Audit
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
correct placement of destructive actions, truthful scope signals,
stable canonical nouns across shells, presence of a single dominant
next action where surface metadata exposes one, absence of duplicate
visible decision summaries, explicit raw/support gating or secondary
placement where the surface serves multiple audience classes,
absence of fake-native primary controls where metadata says the
surface is native, bounded shared family contracts where metadata
says a family is reused, explicit state ownership where specs or
metadata expose it, and dedicated tests for every approved
exception.
stable canonical nouns across shells, absence of fake-native primary
controls where metadata says the surface is native, bounded shared
family contracts where metadata says a family is reused, explicit
state ownership where specs or metadata expose it, and dedicated
tests for every approved exception.
#### Immediate Retrofit Priorities
@ -1602,10 +1421,6 @@ #### Appendix A - One-page Condensed Constitution
- Scope chips must be truthful.
- Domain nouns are canonical and stable.
- Critical operational truth is default-visible.
- Multi-audience detail/status surfaces keep customer-readable decision
content above operator diagnostics and support/raw evidence.
- One dominant next action stays visually primary.
- Duplicate visible decision truth is forbidden.
- Semantic truth dimensions are not collapsed into a generic status.
- Standard lists stay scanable.
- Exceptions are catalogued, justified, and tested.
@ -1618,8 +1433,6 @@ #### Appendix B - Feature Review Checklist
- The human-in-the-loop moment is explicit.
- Immediate-visible decision information is explicit.
- On-demand evidence / diagnostics boundaries are explicit.
- Audience-aware default visibility and raw-evidence boundaries are
explicit where the page serves more than one audience class.
- Any new primary surface is justified against an existing decision
context.
- Navigation reflects a workflow rather than storage structure.
@ -1629,8 +1442,6 @@ #### Appendix B - Feature Review Checklist
- Broad action-surface class is declared.
- Detailed surface type is declared.
- The one most likely next operator action is explicit.
- One dominant next action stays primary.
- Duplicate visible decision truth is absent.
- The surface is classified correctly as native, custom, or shared
family.
- Primary inspect/open model is defined.
@ -1712,10 +1523,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
- Local Blade/Tailwind cards are allowed only when they preserve dark
mode correctness, spacing consistency, badge semantics, action
hierarchy, progressive disclosure, accessibility, and overall
Filament visual language.
Native-by-default classification
- `Native Surface` means the primary interaction contract is built from
@ -1747,8 +1554,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
define shared core vs host variation before another host reassembles
it locally.
- Local one-off markup MUST NOT recreate decision/diagnostics/raw
layering when an existing shared detail family is sufficient.
Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
@ -1762,9 +1567,7 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- and the deviation is justified briefly in code and in the governing spec or PR.
- Approved exceptions MUST stay layout-neutral, use the minimum local
classes necessary, MUST NOT invent a new page-local status language,
MUST preserve dark mode correctness, spacing consistency,
badge semantics, action hierarchy, progressive disclosure,
accessibility, and MUST say what remains standardized.
and MUST say what remains standardized.
- `Hidden Exception` is forbidden. Historical accident or local
implementation convenience is not a valid substitute for UI-EX-001.
@ -1773,8 +1576,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken,
- whether the surface is native, custom, or a shared detail family,
- whether any local Blade/Tailwind card still preserves Filament
visual language and disclosure semantics,
- and whether any ad-hoc status, emphasis styling, or fake-native
contract was introduced.
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
@ -1807,17 +1608,6 @@ ### Scope, Compliance, and Review Expectations
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
- Specs and PRs that create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` MUST reuse the
central OperationRun Start UX Contract, keep queued DB notifications explicit opt-in unless the active spec states a
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
and migration decision.
- Specs and PRs that change detail or status surfaces MUST explicitly
document how they satisfy customer-readable decision-first content,
diagnostics-secondary disclosure, support/raw-evidence gating, one
dominant next action, duplicate-truth prevention, and shared-pattern
reuse.
- Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change.
@ -1835,4 +1625,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19

View File

@ -1,10 +1,5 @@
# Research T186 — settings_apply capability verification (LEGACY / DEPRECATED)
> **Status:** Superseded
> **Last reviewed:** 2026-04-30
> **Use for:** Historical investigation context only if a later Settings Catalog write-path regression needs provenance
> **Do not use for:** Active feature research or current implementation truth
> DEPRECATED: Do not add new research notes under `.specify/`.
> Active feature research should live under `specs/<NNN>-<slug>/`.
> Legacy history lives under `spechistory/`.

View File

@ -40,13 +40,9 @@ mkdir -p "$FEATURE_DIR"
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
if [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
if ! $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN"
fi
echo "Copied plan template to $IMPL_PLAN"
else
if ! $JSON_MODE; then
echo "Warning: Plan template not found at $TEMPLATE"
fi
echo "Warning: Plan template not found at $TEMPLATE"
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi

View File

@ -32,38 +32,18 @@ ## Shared Pattern Reuse
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## OperationRun Start UX Contract
- [ ] CHK019 The change explicitly says whether it creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`, and the required `OperationRun UX Impact` section exists when applicable.
- [ ] CHK020 Queued toast/link/artifact-link/browser-event/dedupe-or-blocked messaging and tenant/workspace-safe operation URL resolution are delegated to the shared OperationRun UX contract instead of local surface code.
- [ ] CHK021 Any queued DB notification is explicit opt-in in the active spec or plan, running DB notifications remain absent, and terminal notifications still flow through the central lifecycle mechanism.
- [ ] CHK022 Any exception records the explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision.
## Provider Boundary And Vocabulary
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
## Signals, Exceptions, And Test Depth
- [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
## Audience-Aware Disclosure And Decision Hierarchy
- [ ] CHK023 Default-visible content is decision-first and clearly separated from operator diagnostics and support/raw evidence.
- [ ] CHK024 Customer/read-only default paths do not expose raw JSON, copied context payloads, fingerprints, internal reason ownership, platform reason families, monitoring detail, or other debug semantics by default.
- [ ] CHK025 Exactly one dominant next action is primary; navigation or debug helpers such as `Open operation`, `Technical details`, or `Show JSON` do not compete at equal weight.
- [ ] CHK026 Duplicate visible status, blocker, reason, impact, or next-action summaries are removed or explicitly justified as non-duplicative evidence.
- [ ] CHK027 Support/raw sections are collapsed, lower-priority, or capability-gated where applicable, and any local Blade/Tailwind surface still preserves Filament visual language, dark mode correctness, progressive disclosure, and accessibility.
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
## Review Outcome
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
## Notes

View File

@ -36,10 +36,6 @@ ## UI / Surface Guardrail Plan
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
- **Shared-family relevance**: [none / list affected shared families]
- **State layers in scope**: [shell / page / detail / URL-query / none]
- **Audience modes in scope**: [customer/read-only / operator-MSP / support-platform / N/A]
- **Decision/diagnostic/raw hierarchy plan**: [decision-first / diagnostics-second / support-raw-third / N/A]
- **Raw/support gating plan**: [collapsed / capability-gated / role-gated / N/A]
- **One-primary-action / duplicate-truth control**: [how one dominant next action is preserved and repeated blockers are removed]
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
@ -58,29 +54,6 @@ ## Shared Pattern & System Fit
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: [yes / no / N/A]
- **Central contract reused**: [shared OperationRun UX layer / `N/A`]
- **Delegated UX behaviors**: [queued toast / run link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Surface-owned behavior kept local**: [initiation inputs only / none / short explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception path**: [none / spec decision + architecture note + test rationale + temporary migration follow-up]
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
- **Provider-owned seams**: [List or `N/A`]
- **Platform-core seams**: [List or `N/A`]
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
- **Retained provider-specific semantics and why**: [none / short explanation]
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
@ -95,8 +68,7 @@ ## Constitution Check
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- OperationRun start UX: any feature that creates, queues, deduplicates, resumes, blocks, completes, or links `OperationRun` reuses the central OperationRun Start UX Contract; no local composition of queued toast/link/event/start-state messaging; `OperationRun UX Impact` is present in the active spec or plan
- Ops-UX 3-surface feedback: if `OperationRun` is used, default feedback is toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); queued DB notifications remain explicit opt-in through the shared start UX contract; running DB notifications stay disallowed
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
@ -110,15 +82,10 @@ ## Constitution Check
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
still necessary, they preserve dark mode correctness, spacing
consistency, badge semantics, action hierarchy, progressive
disclosure, accessibility, and Filament visual language
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
- Decision-first operating model (DECIDE-001): each changed
operator-facing surface is classified as Primary Decision,
@ -128,13 +95,6 @@ ## Constitution Check
disclosed, one governance case stays decidable in one context where
practical, navigation follows workflows not storage structures, and
automation / alerts reduce attention load instead of adding noise
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): detail or
status surfaces separate customer-readable decision content,
operator diagnostics, and support/raw evidence; customer-readable
default paths hide raw JSON, copied context, fingerprints, internal
reason ownership, platform reason families, and debug semantics;
one dominant next action is explicit; duplicate visible truth is
removed
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable

View File

@ -47,26 +47,6 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local 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?**: [yes/no]
- **Shared OperationRun UX contract/layer reused**: [Name it or `N/A`]
- **Delegated start/completion UX behaviors**: [queued toast / `Open operation` or `View run` link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Local surface-owned behavior that remains**: [initiation inputs only / none / bounded explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception required?**: [none / explicit spec decision + architecture note + test or guard-test rationale + temporary migration follow-up]
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: [yes/no]
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
Use this section to classify UI and surface risk once. If the feature does
@ -89,17 +69,6 @@ ## Decision-First Surface Role *(mandatory when operator-facing surfaces are cha
|---|---|---|---|---|---|---|---|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
If this feature adds or materially changes a detail or status surface,
fill out one row per affected surface. Reuse the same surface names
used above and make the disclosure hierarchy explicit instead of
assuming it.
| 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 |
|---|---|---|---|---|---|---|---|
| e.g. Review inbox | customer-read-only, operator-MSP, support-platform | Current status, why it matters, impact, recommendation, next action | Review history, lifecycle, related evidence, related runs | Raw payloads, fingerprints, reason ownership, platform reason family | `Review evidence` | Raw/support detail hidden or capability-gated outside support mode | The top summary states the blocker once; later sections add evidence rather than restating it |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
@ -265,20 +234,6 @@ ## Requirements *(mandatory)*
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
- which content is hidden, collapsed, or capability-gated by default,
- how one dominant next action is preserved,
- and how duplicate visible truth is prevented.
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
- classify each touched seam as provider-owned or platform-core,
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
- name the neutral platform terms or shared contracts being preserved,
- explain why any retained provider-specific semantics are the narrowest current-release truth,
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof,
@ -291,21 +246,12 @@ ## Requirements *(mandatory)*
- and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the default Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification) and whether any queued DB notification is explicitly opted into,
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (OPS-UX-START-001):** If this feature creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun`, the spec MUST:
- include the `OperationRun UX Impact` section,
- name the shared OperationRun UX contract/layer being reused,
- delegate queued toast/link/artifact-link/browser-event/queued-DB-notification/dedupe-or-blocked messaging/tenant-safe URL resolution to that shared path,
- keep local surface code limited to initiation inputs and operation-specific data capture,
- keep queued DB notifications explicit opt-in unless the spec intentionally defines a different policy,
- route terminal notifications through the central lifecycle mechanism,
- and document any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary follow-up migration decision.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
@ -328,7 +274,6 @@ ## Requirements *(mandatory)*
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
@ -386,7 +331,6 @@ ## Requirements *(mandatory)*
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
- which diagnostics are secondary and how they are explicitly revealed,
- how the dominant next action stays primary and how duplicate visible truth is avoided,
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),

View File

@ -18,22 +18,17 @@ # Tasks: [FEATURE NAME]
- record budget, baseline, or trend follow-up when runtime cost shifts materially,
- and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub through the shared OperationRun start UX path rather than local surface composition.
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- reusing the central OperationRun Start UX Contract instead of composing local queued toast/link/event/dedupe/blocked/start-failure semantics,
- delegating `Open operation` / `View run`, artifact links, run-enqueued browser event, queued DB-notification policy, dedupe / already-available / already-running messaging, blocked / failed-to-start messaging, and tenant/workspace-safe URL resolution to the shared OperationRun UX layer,
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
- keeping queued DB notifications explicit opt-in in the active spec unless a different policy is intentionally approved, and ensuring running DB notifications do not exist,
- routing terminal notifications through the central lifecycle mechanism rather than feature-local notification code,
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system),
- documenting any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision,
- and ensuring the active spec or plan contains an `OperationRun UX Impact` section.
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics:
@ -56,11 +51,6 @@ # Tasks: [FEATURE NAME]
- extending the shared path when it is sufficient for current-release truth,
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
- classifying each touched seam as provider-owned or platform-core,
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
@ -78,21 +68,9 @@ # Tasks: [FEATURE NAME]
- filling the specs Operator Surface Contract for every affected page,
- keeping default-visible content limited to first-decision needs and
moving proof, payloads, and diagnostics into progressive disclosure,
- implementing the three-tier disclosure hierarchy where applicable:
customer-readable decision content first, operator diagnostics
second, support/raw evidence third,
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
- ensuring customer/read-only default paths do not expose raw JSON,
copied context payloads, fingerprints, internal reason ownership,
platform reason families, or debug semantics,
- keeping each governance case decidable in one focused context where
practical instead of forcing cross-page reconstruction,
- keeping exactly one dominant next action primary and demoting
navigation/debug helpers such as `Open operation`, `Technical
details`, or `Show JSON`,
- removing duplicate visible status, blocker, reason, impact, or
next-action summaries so later sections add evidence instead of
restating the same decision truth,
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
@ -140,12 +118,6 @@ # Tasks: [FEATURE NAME]
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
- For any new or modified customer/operator-facing detail surface,
tests MUST prove default-visible status/reason/impact/next-action
content, exactly one dominant next action, diagnostics-secondary
ordering, hidden raw/support detail by default, capability-gated
support/raw sections where applicable, and the absence of duplicate
visible decision summaries.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),

View File

@ -59,13 +59,6 @@ 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

View File

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

View File

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

View File

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

View File

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ProductUsageEvent;
use Illuminate\Console\Command;
class PruneProductUsageEventsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:product-usage:prune {--days= : Number of days to retain product usage events}';
/**
* @var string
*/
protected $description = 'Delete product usage events older than the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('tenantpilot.product_usage_event_retention_days', 90));
if ($days < 1) {
$this->error('Retention days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$deleted = ProductUsageEvent::query()
->where('occurred_at', '<', $cutoff)
->delete();
$this->info("Deleted {$deleted} product usage event(s) older than {$days} days.");
return self::SUCCESS;
}
}

View File

@ -6,14 +6,12 @@
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 (legacy aliases also accepted)}
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
@ -101,35 +99,21 @@ public function handle(): int
*/
private function normalizedTypes(): array
{
$requestedTypes = array_values(array_unique(array_filter(
$types = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
$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,
];
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
}
return array_values(array_unique(array_merge(
...array_map(
static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type),
$canonicalTypes,
),
)));
return array_values(array_filter(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
}
/**

View File

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

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotBackfillFindingLifecycle extends Command
{
protected $signature = 'tenantpilot:findings:backfill-lifecycle
{--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->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 (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<int, string> $tenantIdentifiers
* @return \Illuminate\Support\Collection<int, Tenant>
*/
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();
}
}

View File

@ -4,7 +4,6 @@
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
@ -51,7 +50,7 @@ public function handle(): int
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant,
type: OperationRunType::DirectoryGroupsSync->value,
type: 'entra_group_sync',
identityInputs: [
'selection_key' => $selectionKey,
'slot_key' => $slotKey,

View File

@ -11,7 +11,6 @@
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Support\OperationRunType;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
@ -169,12 +168,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
'tenant_id' => (int) $tenant->id,
'user_id' => null,
'initiator_name' => 'System',
'type' => OperationRunType::BackupSchedulePurge->value,
'type' => 'backup_schedule_purge',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', implode(':', [
(string) $tenant->id,
OperationRunType::BackupSchedulePurge->value,
'backup_schedule_purge',
now()->toISOString(),
Str::uuid()->toString(),
])),

View File

@ -7,9 +7,7 @@
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -30,7 +28,7 @@ public function handle(
$dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query()
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) {

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Services\Runbooks\RunbookReason;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotRunDeployRunbooks extends Command
{
protected $signature = 'tenantpilot:run-deploy-runbooks';
protected $description = 'Run deploy-time runbooks idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
try {
$runbookService->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 (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;
}
}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Entitlements;
final class WorkspaceEntitlementBlockedException extends \RuntimeException
{
/**
* @param array<string, mixed> $decision
*/
public function __construct(private readonly array $decision)
{
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
}
/**
* @return array<string, mixed>
*/
public function decision(): array
{
return $this->decision;
}
}

View File

@ -9,9 +9,4 @@
class Login extends BaseLogin
{
protected string $view = 'filament.pages.auth.login';
public function getTitle(): string
{
return __('localization.auth.sign_in_microsoft');
}
}

View File

@ -21,7 +21,6 @@
use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
@ -490,7 +489,7 @@ private function compareNowAction(): Action
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value)
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
->actions($run instanceof OperationRun ? [
Action::make('view_run')
->label('Open operation')

View File

@ -18,7 +18,6 @@
use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
@ -811,8 +810,8 @@ private function compareAssignedTenants(): void
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')

View File

@ -1,674 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum;
class CrossTenantComparePage extends Page implements HasForms
{
use InteractsWithForms;
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'Cross-Tenant Compare';
protected static ?string $slug = 'cross-tenant-compare';
protected string $view = 'filament.pages.cross-tenant-compare';
public ?string $sourceTenantId = null;
public ?string $targetTenantId = null;
/**
* @var list<string>
*/
public array $selectedPolicyTypes = [];
/**
* @var array<string, mixed>|null
*/
public ?array $navigationContextPayload = null;
/**
* @var array<string, mixed>|null
*/
public ?array $preview = null;
/**
* @var array<string, mixed>|null
*/
public ?array $preflight = null;
public ?string $selectionMessage = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
}
public function mount(): void
{
$this->authorizePageAccess();
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$this->hydrateSelectionFromRequest();
$this->refreshPreview();
$this->form->fill($this->formState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 3,
])
->schema([
Select::make('sourceTenantId')
->label('Source tenant')
->options(fn (): array => $this->tenantOptions())
->searchable()
->preload()
->native(false)
->placeholder('Select a source tenant')
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
Select::make('targetTenantId')
->label('Target tenant')
->options(fn (): array => $this->tenantOptions())
->searchable()
->preload()
->native(false)
->placeholder('Select a target tenant')
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
Select::make('selectedPolicyTypes')
->label('Governed subjects')
->options(fn (): array => $this->policyTypeOptions())
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All governed subjects')
->helperText(fn (): ?string => $this->policyTypeOptions() === []
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
: null)
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
]),
]);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('return_to_origin')
->label($navigationContext->backLinkLabel)
->icon('heroicon-o-arrow-left')
->color('gray')
->url($navigationContext->backLinkUrl);
}
$sourceTenant = $this->selectedSourceTenant();
if ($sourceTenant instanceof Tenant) {
$actions[] = Action::make('open_source_tenant')
->label('Open source tenant')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
}
$targetTenant = $this->selectedTargetTenant();
if ($targetTenant instanceof Tenant) {
$actions[] = Action::make('open_target_tenant')
->label('Open target tenant')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
}
$preflightAction = Action::make('generatePromotionPreflight')
->label('Generate promotion preflight')
->icon('heroicon-o-sparkles')
->color('primary')
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
->tooltip(fn (): ?string => $this->preflightDisabledReason())
->action(fn (): mixed => $this->generatePromotionPreflight());
$preflightAction = WorkspaceUiEnforcement::forAction(
$preflightAction,
fn (): ?Workspace => $this->workspace(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
->apply()
->tooltip(function (): ?string {
$user = auth()->user();
$workspace = $this->workspace();
if ($user instanceof User && $workspace instanceof Workspace) {
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if ($resolver->isMember($user, $workspace)
&& ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
return 'You need workspace baseline manage access to generate a promotion preflight.';
}
}
return $this->preflightDisabledReason();
});
$actions[] = $preflightAction;
return $actions;
}
public function applySelection(): void
{
$this->selectionMessage = null;
$this->preflight = null;
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
if ($this->sourceTenantId !== null
&& $this->targetTenantId !== null
&& $this->sourceTenantId === $this->targetTenantId) {
$this->selectionMessage = 'Choose two different tenants.';
$this->addError('targetTenantId', $this->selectionMessage);
return;
}
$this->redirect($this->selectionUrl(), navigate: true);
}
public function generatePromotionPreflight(): void
{
$this->authorizePageAccess();
$this->authorizePreflightExecution();
if ($this->preview === null) {
$this->refreshPreview();
}
if ($this->preview === null) {
return;
}
$selection = $this->compareSelection();
if (! $selection instanceof CrossTenantCompareSelection) {
return;
}
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
$workspace = $this->workspace();
$user = auth()->user();
if ($workspace instanceof Workspace && $user instanceof User) {
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
workspace: $workspace,
sourceTenant: $selection->sourceTenant,
targetTenant: $selection->targetTenant,
preflight: $this->preflight,
actor: $user,
);
}
}
public function clearSelectionUrl(): string
{
return static::getUrl($this->routeParameters([
self::SOURCE_TENANT_QUERY_KEY => null,
self::TARGET_TENANT_QUERY_KEY => null,
self::POLICY_TYPE_QUERY_KEY => null,
]), panel: 'admin');
}
public function selectionUrl(): string
{
return static::getUrl($this->routeParameters(), panel: 'admin');
}
public static function launchUrl(
?Tenant $sourceTenant = null,
?Tenant $targetTenant = null,
?CanonicalNavigationContext $navigationContext = null,
): string {
$parameters = [];
if ($sourceTenant instanceof Tenant) {
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
}
if ($targetTenant instanceof Tenant) {
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
}
if ($navigationContext instanceof CanonicalNavigationContext) {
$parameters = array_replace($parameters, $navigationContext->toQuery());
}
return static::getUrl($parameters, panel: 'admin');
}
public function hasActiveSelection(): bool
{
return $this->sourceTenantId !== null
|| $this->targetTenantId !== null
|| $this->selectedPolicyTypes !== [];
}
public function stateColor(string $state): string
{
return match ($state) {
'match', 'ready' => 'success',
'different', 'manual_mapping_required' => 'warning',
'missing' => 'info',
'ambiguous' => 'gray',
'blocked' => 'danger',
default => 'gray',
};
}
public function stateLabel(string $value): string
{
return Str::headline(str_replace('_', ' ', $value));
}
public function reasonLabel(string $reasonCode): string
{
return Str::headline(str_replace('_', ' ', $reasonCode));
}
public function sourceTenantUrl(): ?string
{
$tenant = $this->selectedSourceTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}
public function targetTenantUrl(): ?string
{
$tenant = $this->selectedTargetTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}
/**
* @return array<string, mixed>
*/
private function formState(): array
{
return [
'sourceTenantId' => $this->sourceTenantId,
'targetTenantId' => $this->targetTenantId,
'selectedPolicyTypes' => $this->selectedPolicyTypes,
];
}
private function hydrateSelectionFromRequest(): void
{
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
}
private function refreshPreview(): void
{
$this->selectionMessage = null;
$this->preview = null;
$this->preflight = null;
$selection = $this->compareSelection();
if (! $selection instanceof CrossTenantCompareSelection) {
return;
}
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
private function authorizePreflightExecution(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
abort(403);
}
}
private function compareSelection(): ?CrossTenantCompareSelection
{
$sourceTenant = $this->selectedSourceTenant();
$targetTenant = $this->selectedTargetTenant();
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
return null;
}
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
$this->selectionMessage = 'Choose two different tenants.';
return null;
}
return new CrossTenantCompareSelection(
sourceTenant: $sourceTenant,
targetTenant: $targetTenant,
policyTypes: $this->selectedPolicyTypes,
);
}
private function selectedSourceTenant(): ?Tenant
{
if ($this->sourceTenantId === null) {
return null;
}
return $this->resolveAuthorizedTenant($this->sourceTenantId);
}
private function selectedTargetTenant(): ?Tenant
{
if ($this->targetTenantId === null) {
return null;
}
return $this->resolveAuthorizedTenant($this->targetTenantId);
}
private function resolveAuthorizedTenant(string $tenantId): Tenant
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
abort(404);
}
$tenant = Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereKey((int) $tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
abort(404);
}
return $tenant;
}
/**
* @return array<string, string>
*/
private function tenantOptions(): array
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return [];
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$tenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->select('tenants.*')
->orderBy('tenants.name')
->get();
$resolver->primeMemberships($user, $tenants->modelKeys());
return $tenants
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
->mapWithKeys(fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
/**
* @return array<string, string>
*/
private function policyTypeOptions(): array
{
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
if ($tenantIds === []) {
return [];
}
return InventoryItem::query()
->whereIn('tenant_id', $tenantIds)
->whereNotNull('policy_type')
->where('policy_type', '!=', '')
->distinct()
->orderBy('policy_type')
->pluck('policy_type')
->mapWithKeys(fn (string $policyType): array => [
$policyType => Str::headline($policyType),
])
->all();
}
private function preflightDisabledReason(): ?string
{
if ($this->selectionMessage !== null) {
return $this->selectionMessage;
}
if (! is_array($this->preview)) {
return 'Select an authorized source and target tenant to generate a promotion preflight.';
}
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
return 'No governed subjects are available for this compare selection yet.';
}
return null;
}
/**
* @param mixed $value
*/
private function normalizeTenantIdentifier(mixed $value): ?string
{
if (! is_string($value) && ! is_int($value)) {
return null;
}
$normalized = trim((string) $value);
return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null;
}
/**
* @param mixed $value
* @return list<string>
*/
private function normalizePolicyTypes(mixed $value): array
{
$allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true);
$values = match (true) {
is_string($value) && $value !== '' => [$value],
is_array($value) => $value,
default => [],
};
return array_values(array_filter(array_unique(array_map(
static fn (mixed $item): string => is_string($item) ? trim($item) : '',
$values,
)), static fn (string $item): bool => $item !== '' && isset($allowed[$item])));
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function routeParameters(array $overrides = []): array
{
$parameters = [
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
];
if (is_array($this->navigationContextPayload)) {
$parameters['nav'] = $this->navigationContextPayload;
}
foreach ($overrides as $key => $value) {
$parameters[$key] = $value;
}
return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
return $workspace instanceof Workspace ? $workspace : null;
}
}

View File

@ -105,26 +105,14 @@ public function mount(): void
protected function getHeaderActions(): array
{
$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')
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->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;
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
@ -710,15 +698,6 @@ 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)

View File

@ -97,26 +97,14 @@ public function mount(): void
protected function getHeaderActions(): array
{
$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')
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->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;
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
@ -652,15 +640,6 @@ 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();

View File

@ -1,520 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
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\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class GovernanceInbox extends Page
{
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Governance inbox';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Governance inbox';
protected static ?string $slug = 'governance/inbox';
protected string $view = 'filament.pages.governance.governance-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleFindingTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $reviewTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $inboxPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredInboxPayload = null;
private ?Workspace $workspace = null;
private ?bool $visibleAlertsFamily = null;
private ?bool $visibleFindingExceptionsFamily = 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<string, mixed>
*/
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<array{key: string, label: string, count: int}>
*/
public function availableFamilies(): array
{
return $this->inboxPayload()['available_families'] ?? [];
}
/**
* @return list<array<string, mixed>>
*/
public function sections(): array
{
return $this->inboxPayload()['sections'] ?? [];
}
/**
* @return array<string, mixed>
*/
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 CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkUrl: $this->pageUrl(),
familyKey: $this->family,
);
}
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->hasVisibleFindingExceptionsFamily()
|| $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);
}
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<int, Tenant>
*/
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<int, Tenant>
*/
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<int, Tenant>
*/
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',
'finding_exceptions',
'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<string, mixed>
*/
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(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
navigationContext: $this->navigationContext(),
);
}
/**
* @return array<string, mixed>
*/
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(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
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;
}
}

View File

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

View File

@ -208,16 +208,6 @@ 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')
@ -489,10 +479,7 @@ public function selectedExceptionUrl(): ?string
return null;
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
}
public function selectedFindingUrl(): ?string
@ -503,10 +490,7 @@ public function selectedFindingUrl(): ?string
return null;
}
return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}
public function clearSelectedException(): void
@ -670,15 +654,6 @@ 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) {
@ -808,16 +783,4 @@ private function governanceWarningColor(FindingException $record): string
return 'danger';
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -6,10 +6,8 @@
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
@ -18,21 +16,13 @@
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity;
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;
use App\Support\Tenants\TenantOperabilityQuestion;
@ -43,16 +33,10 @@
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
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;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
@ -95,11 +79,6 @@ public function getTitle(): string|Htmlable
*/
public ?array $navigationContextPayload = null;
/**
* @var list<string>
*/
public array $supportDiagnosticsAuditKeys = [];
/**
* @return array<Action|ActionGroup>
*/
@ -131,14 +110,14 @@ protected function getHeaderActions(): array
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')
->color('gray')
->url(fn (): string => OperationRunLinks::index());
->url(fn (): string => route('admin.operations.index'));
}
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations')
->color('gray')
->url(fn (): string => OperationRunLinks::index());
->url(fn (): string => route('admin.operations.index'));
}
$actions[] = Action::make('refresh')
@ -147,7 +126,7 @@ protected function getHeaderActions(): array
->color('primary')
->url(fn (): string => isset($this->run)
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: OperationRunLinks::index());
: route('admin.operations.index'));
if (! isset($this->run)) {
return $actions;
@ -171,14 +150,6 @@ protected function getHeaderActions(): array
->color('gray');
}
$actions[] = ActionGroup::make([
$this->openSupportDiagnosticsAction(),
$this->requestSupportAction(),
])
->label('More')
->icon('heroicon-o-ellipsis-horizontal')
->color('gray');
$actions[] = $this->resumeCaptureAction();
return $actions;
@ -235,361 +206,6 @@ public function monitoringDetailSummary(): array
];
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy')
->color('gray')
->record($this->run)
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading('Support diagnostics')
->modalDescription('Redacted operation context from existing records.')
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->mountUsing(function (): void {
$this->auditOperationSupportDiagnosticsOpen();
})
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
'bundle' => $this->operationRunSupportDiagnosticBundle(),
]));
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
->apply();
}
public function authorizeOperationRunSupportRequest(): void
{
$this->resolveRunTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
}
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->record($this->run)
->slideOver()
->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_run_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('primary_context')
->label(__('localization.dashboard.primary_context'))
->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(),
Placeholder::make('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(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveViewerActor()->email),
])
->action(function (array $data): void {
$actor = $this->resolveViewerActor();
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make()
->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();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
->apply();
}
/**
* @return array<string, mixed>
*/
public function operationRunSupportDiagnosticBundle(): array
{
$user = $this->resolveViewerActor();
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
}
private function auditOperationSupportDiagnosticsOpen(): void
{
$user = $this->resolveViewerActor();
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
bundle: $this->operationRunSupportDiagnosticBundle(),
user: $user,
);
}
private function supportDiagnosticsTenant(): ?Tenant
{
if (! isset($this->run)) {
return null;
}
$tenant = $this->run->tenant;
if ($tenant instanceof Tenant) {
return $tenant;
}
return $this->run->loadMissing('tenant')->tenant;
}
private function resolveViewerActor(): User
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
return $user;
}
private function resolveRunTenantForCapability(string $capability): Tenant
{
$tenant = $this->supportDiagnosticsTenant();
$user = $this->resolveViewerActor();
if (! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, $capability)) {
abort(403);
}
return $tenant;
}
private function operationSupportRequestAttachmentSummary(): string
{
$tenant = $this->supportDiagnosticsTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return 'Only canonical redacted run context will be attached.';
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return 'Only canonical redacted run context will be attached.';
}
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
? 'A redacted diagnostic snapshot and the canonical run context will be attached.'
: '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<string, string>
*/
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<string, mixed>|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<string, mixed> $bundle
*/
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
{
if (! isset($this->run)) {
return;
}
$auditKey = 'operation:'.$this->run->getKey();
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
return;
}
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
tenant: $tenant,
contextType: 'operation_run',
bundle: $bundle,
actor: $user,
operationRun: $this->run,
);
app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
workspaceId: (int) $tenant->workspace_id,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'operation_run',
subjectId: (int) $this->run->getKey(),
metadata: [
'source_surface' => 'operation_run_viewer',
'operation_type' => (string) $this->run->type,
],
);
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
public function mount(OperationRun $run): void
{
$user = auth()->user();
@ -891,14 +507,12 @@ private function canResumeCapture(): bool
return false;
}
$canonicalType = OperationCatalog::canonicalCode((string) $this->run->type);
if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
return false;
}
$context = is_array($this->run->context) ? $this->run->context : [];
$tokenKey = $canonicalType === OperationRunType::BaselineCapture->value
$tokenKey = (string) $this->run->type === 'baseline_capture'
? 'baseline_capture.resume_token'
: 'baseline_compare.resume_token';
$token = data_get($context, $tokenKey);

View File

@ -1,695 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\EvidenceSnapshot;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Audit\AuditActionId;
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;
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;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class CustomerReviewWorkspace extends Page implements HasTable
{
use InteractsWithTable;
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
public const string SOURCE_SURFACE = 'customer_review_workspace';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Customer reviews';
protected static ?int $navigationSort = 44;
protected static ?string $title = 'Customer Review Workspace';
protected static ?string $slug = 'reviews/workspace';
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');
}
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)
? (string) $tenant->external_id
: (string) $tenant->getKey();
return static::getUrl(panel: 'admin').'?'.http_build_query([
'tenant' => $tenantIdentifier,
]);
}
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public function mount(): void
{
$this->authorizePageAccess();
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->auditWorkspaceOpen();
}
protected function getHeaderActions(): array
{
$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')
->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
{
return $table
->query(fn (): Builder => $this->workspaceQuery())
->defaultSort('name')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review')
->label(__('localization.review.latest_review'))
->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(),
TextColumn::make('finding_summary')
->label(__('localization.review.key_findings'))
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label(__('localization.review.accepted_risks'))
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('evidence_proof_state')
->label(__('localization.review.evidence_proof'))
->getStateUsing(fn (Tenant $record): string => $this->evidenceProofAvailability($record))
->wrap(),
TextColumn::make('published_at')
->label(__('localization.review.published'))
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label(__('localization.review.review_pack'))
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record))
->wrap(),
])
->filters([
SelectFilter::make('tenant_id')
->label(__('localization.review.tenant'))
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder {
$tenantId = $data['value'] ?? null;
return is_numeric($tenantId)
? $query->whereKey((int) $tenantId)
: $query;
})
->searchable(),
])
->actions([
Action::make('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(__('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(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? __('localization.review.clear_filters_description')
: __('localization.review.adjust_filters_description'))
->emptyStateActions([
Action::make('clear_filters_empty')
->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(fn (): mixed => $this->clearWorkspaceFilters()),
]);
}
/**
* @return array<int, Tenant>
*/
public 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 = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
throw new NotFoundHttpException;
}
if ($this->authorizedTenants() === []) {
throw new NotFoundHttpException;
}
}
private function auditWorkspaceOpen(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::CustomerReviewWorkspaceOpened,
context: [
'metadata' => [
'source_surface' => self::SOURCE_SURFACE,
'tenant_filter_id' => $this->currentTenantFilterId(),
'entitled_tenant_count' => count($this->authorizedTenants()),
],
],
actor: $user,
resourceType: 'customer_review_workspace',
resourceId: (string) $workspace->getKey(),
targetLabel: __('localization.review.customer_review_workspace'),
);
}
private function workspaceQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Tenant::query()->whereRaw('1 = 0');
}
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function defaultTenantFilter(): ?string
{
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
? (string) $tenantId
: null;
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
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->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function hasActiveFilters(): bool
{
return $this->currentTenantFilterId() !== null;
}
private function clearWorkspaceFilters(): void
{
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilters();
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_numeric($workspaceId)
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
private function latestPublishedReview(Tenant $tenant): ?TenantReview
{
$review = $tenant->tenantReviews->first();
return $review instanceof TenantReview ? $review : null;
}
private function latestReviewUrl(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return null;
}
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
{
$review = $this->latestPublishedReview($tenant);
$pack = $review?->currentExportReviewPack;
return $pack instanceof ReviewPack ? $pack : null;
}
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
{
$user = auth()->user();
$pack = $this->latestReviewPack($tenant);
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => self::SOURCE_SURFACE,
]);
}
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
{
return $this->latestPublishedReview($tenant)?->published_at;
}
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
{
$review = $this->latestPublishedReview($tenant);
return $review instanceof TenantReview
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
: null;
}
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);
$review = $this->latestPublishedReview($tenant);
$truth = $this->reviewTruth($tenant);
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
}
private function latestReviewStateLabel(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
}
private function latestReviewStateColor(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
}
private function latestReviewStateIcon(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
}
private function latestReviewStateIconColor(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
$summary = is_array($review->summary) ? $review->summary : [];
$findingOutcomes = $summary['finding_outcomes'] ?? null;
if (! is_array($findingOutcomes)) {
return $primaryReason;
}
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$summary = is_array($review->summary) ? $review->summary : [];
$findingCount = (int) ($summary['finding_count'] ?? 0);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) {
return __('localization.review.no_findings_recorded');
}
if ($terminalOutcomes === null) {
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
}
return __('localization.review.findings_count_with_outcomes', [
'count' => $findingCount,
'outcomes' => $terminalOutcomes,
]);
}
private function acceptedRiskSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$summary = is_array($review->summary) ? $review->summary : [];
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
$countSummary = match (true) {
$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]),
};
$accountability = $this->acceptedRiskAccountability($tenant);
return $accountability === null
? $countSummary
: $countSummary.' '.$accountability;
}
private function reviewPackAvailability(Tenant $tenant): string
{
if (! $this->latestPublishedReview($tenant) instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$pack = $this->latestReviewPack($tenant);
$user = auth()->user();
if (! $pack instanceof ReviewPack) {
return __('localization.review.no_current_review_pack');
}
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return __('localization.review.review_pack_access_unavailable');
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return __('localization.review.review_pack_unavailable');
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return __('localization.review.review_pack_expired');
}
return __('localization.review.review_pack_available');
}
private function evidenceProofAvailability(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$snapshot = $review->evidenceSnapshot;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot) {
return __('localization.review.evidence_proof_absent');
}
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return __('localization.review.evidence_proof_access_unavailable');
}
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
return __('localization.review.evidence_proof_expired');
}
return __('localization.review.evidence_proof_available');
}
private function acceptedRiskAccountability(Tenant $tenant): ?string
{
$exception = FindingException::query()
->with(['owner', 'approver', 'currentDecision'])
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->current()
->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end")
->latest('approved_at')
->latest('requested_at')
->latest('id')
->first();
if (! $exception instanceof FindingException) {
return null;
}
$accountable = $exception->owner?->name
?? $exception->approver?->name;
$decisionType = $exception->currentDecision?->decision_type;
$reviewDue = $exception->review_due_at ?? $exception->expires_at;
$reason = is_string($exception->request_reason) ? trim($exception->request_reason) : '';
$parts = [];
if (is_string($accountable) && trim($accountable) !== '') {
$parts[] = $reviewDue === null
? __('localization.review.accepted_risk_accountable', ['name' => $accountable])
: __('localization.review.accepted_risk_accountable_until', [
'name' => $accountable,
'date' => $reviewDue->toDateString(),
]);
} elseif (is_string($decisionType) && trim($decisionType) !== '') {
$parts[] = __('localization.review.accepted_risk_partial_accountability');
}
if ($reason !== '') {
$parts[] = __('localization.review.accepted_risk_reason', [
'reason' => Str::limit($reason, 160),
]);
}
return $parts === [] ? null : implode(' ', $parts);
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -9,13 +9,11 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles;
@ -177,24 +175,6 @@ public function table(Table $table): Table
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
&& 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(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([])
@ -372,14 +352,7 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
private function reviewOutcomeDescription(TenantReview $record): ?string
{
$primaryReason = $this->reviewOutcome($record)->primaryReason;
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
return $this->reviewOutcome($record)->primaryReason;
}
private function reviewOutcomeNextStep(TenantReview $record): string
@ -400,16 +373,4 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
SurfaceCompressionContext::reviewRegister(),
);
}
private function findingOutcomeSummary(TenantReview $record): ?string
{
$summary = is_array($record->summary) ? $record->summary : [];
$outcomeCounts = $summary['finding_outcomes'] ?? [];
if (! is_array($outcomeCounts)) {
return null;
}
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
}
}

View File

@ -7,12 +7,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
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;
@ -25,9 +20,7 @@
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -58,8 +51,6 @@ class WorkspaceSettings extends Page
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/
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'],
@ -67,23 +58,10 @@ class WorkspaceSettings extends Page
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
];
/**
* @var array<string, string>
*/
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
@ -133,14 +111,6 @@ class WorkspaceSettings extends Page
*/
public array $resolvedSettings = [];
/**
* @var array{
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions?: array<string, array<string, mixed>>
* }
*/
public array $entitlementSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
@ -155,22 +125,17 @@ protected function getHeaderActions(): array
{
return [
Action::make('save')
->label(__('localization.workspace.save'))
->label('Save')
->action(function (): void {
$this->save();
})
->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage()
? null
: __('localization.workspace.no_manage_permission')),
: 'You do not have permission to manage workspace settings.'),
];
}
public function getTitle(): string
{
return __('localization.workspace.title');
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -215,83 +180,6 @@ 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)
->schema([
Select::make('entitlements_plan_profile')
->label('Plan profile')
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
->native(false)
->columnSpanFull()
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->planProfileFieldHelperText()),
TextInput::make('entitlements_managed_tenant_limit_override_value')
->label('Managed tenant activation limit override')
->placeholder('Unset (uses plan profile default)')
->suffix('tenants')
->hint('0 or greater')
->numeric()
->integer()
->minValue(0)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitHelperText())
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
Textarea::make('entitlements_managed_tenant_limit_override_reason')
->label('Managed tenant activation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
Select::make('entitlements_review_pack_generation_override_value')
->label('Review pack generation override')
->options(self::booleanOptions())
->placeholder('Unset (uses plan profile default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
Textarea::make('entitlements_review_pack_generation_override_reason')
->label('Review pack generation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
]),
Section::make('Workspace AI policy')
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
->schema([
Select::make('ai_policy_mode')
->label('AI posture')
->options(AiPolicyMode::optionLabels())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->aiPolicyModeHelperText())
->hintAction($this->makeResetAction('ai_policy_mode')),
Placeholder::make('ai_approved_use_cases')
->label('Approved use cases')
->content(fn (): string => $this->aiApprovedUseCasesText()),
Placeholder::make('ai_allowed_provider_classes')
->label('Allowed provider classes')
->content(fn (): string => $this->aiAllowedProviderClassesText()),
Placeholder::make('ai_blocked_data_classifications')
->label('Blocked data classifications')
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
]),
Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([
@ -526,7 +414,7 @@ public function save(): void
$this->loadFormState();
Notification::make()
->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
->success()
->send();
}
@ -545,7 +433,7 @@ public function resetSetting(string $field): void
if ($this->workspaceOverrideForField($field) === null) {
Notification::make()
->title(__('localization.notifications.setting_already_default'))
->title('Setting already uses default')
->success()
->send();
@ -562,57 +450,7 @@ public function resetSetting(string $field): void
$this->loadFormState();
Notification::make()
->title(__('localization.notifications.workspace_setting_reset'))
->success()
->send();
}
private function resetEntitlementOverridePair(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
if (! $this->hasEntitlementOverridePair($field)) {
Notification::make()
->title('Entitlement already uses plan profile default')
->success()
->send();
return;
}
$writer = app(SettingsWriter::class);
$valueSetting = $this->settingForField($field);
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
$reasonSetting = $this->settingForField($reasonField);
if ($this->workspaceOverrideForField($field) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $valueSetting['domain'],
key: $valueSetting['key'],
);
}
if ($this->workspaceOverrideForField($reasonField) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $reasonSetting['domain'],
key: $reasonSetting['key'],
);
}
$this->loadFormState();
Notification::make()
->title('Workspace entitlement override reset')
->title('Workspace setting reset to default')
->success()
->send();
}
@ -652,7 +490,6 @@ private function loadFormState(): void
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
$this->loadDomainLastModified();
}
@ -711,240 +548,37 @@ private function sectionDescription(string $domain, string $baseDescription): st
/** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at'];
return __('localization.workspace.last_modified_by', [
'description' => $baseDescription,
'user' => $meta['user_name'],
'time' => $updatedAt->diffForHumans(),
]);
return sprintf(
'%s — Last modified by %s, %s.',
$baseDescription,
$meta['user_name'],
$updatedAt->diffForHumans(),
);
}
private function makeResetAction(string $field): Action
{
return Action::make('reset_'.$field)
->label(__('localization.workspace.reset'))
->label('Reset')
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
if ($this->isEntitlementOverrideValueField($field)) {
$this->resetEntitlementOverridePair($field);
return;
}
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return __('localization.workspace.no_manage_permission');
return 'You do not have permission to manage workspace settings.';
}
if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) {
return __('localization.workspace.no_workspace_override');
}
return __('localization.workspace.no_workspace_override');
if (! $this->hasWorkspaceOverride($field)) {
return 'No workspace override to reset.';
}
return null;
});
}
private function canResetField(string $field): bool
{
if ($this->isEntitlementOverrideValueField($field)) {
return $this->hasEntitlementOverridePair($field);
}
return $this->hasWorkspaceOverride($field);
}
private function isEntitlementOverrideValueField(string $field): bool
{
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
}
private function hasEntitlementOverridePair(string $field): bool
{
if (! $this->isEntitlementOverrideValueField($field)) {
return false;
}
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
return $this->workspaceOverrideForField($field) !== null
|| $this->workspaceOverrideForField($reasonField) !== null;
}
private function planProfileFieldHelperText(): string
{
$profile = $this->resolvedPlanProfile();
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
if (! is_string($selectedProfile) || $selectedProfile === '') {
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
}
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
}
private function managedTenantLimitHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$capacityText = $remainingCapacity < 0
? sprintf('Over limit by %d.', abs($remainingCapacity))
: sprintf('%d remaining.', $remainingCapacity);
return sprintf(
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
$effectiveValue,
$currentUsage,
$capacityText,
$this->entitlementSourceLabel($decision),
);
}
private function managedTenantLimitReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_managed_tenant_limit_override_value',
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function reviewPackGenerationHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
return sprintf(
'Effective state: %s. Source: %s.',
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
$this->entitlementSourceLabel($decision),
);
}
private function reviewPackGenerationReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_review_pack_generation_override_value',
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function aiPolicyModeHelperText(): string
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return '';
}
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
: sprintf('Effective posture: %s.', $mode->label());
return sprintf('%s %s', $prefix, $mode->summary());
}
private function aiApprovedUseCasesText(): string
{
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
}
private function aiAllowedProviderClassesText(): string
{
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
if ($labels === []) {
return 'No provider classes are allowed while AI is disabled.';
}
return implode(', ', $labels).'.';
}
private function aiBlockedDataClassificationsText(): string
{
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
}
private function effectiveAiPolicyMode(): AiPolicyMode
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return AiPolicyMode::Disabled;
}
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
}
private function entitlementReasonHelperText(string $valueField, string $key): string
{
$decision = $this->entitlementDecision($key);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
if ($this->workspaceOverrideForField($valueField) === null) {
return 'Required when an explicit override value is set.';
}
if ($rationale === null || $rationale === '') {
return 'Required when an explicit override value is set.';
}
return sprintf('Current rationale: %s', $rationale);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
private function resolvedPlanProfile(): array
{
$profile = $this->entitlementSummary['plan_profile'] ?? null;
if (is_array($profile)) {
return $profile;
}
return app(WorkspacePlanProfileCatalog::class)->default();
}
/**
* @return array<string, mixed>
*/
private function entitlementDecision(string $key): array
{
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
return is_array($decision) ? $decision : [];
}
/**
* @param array<string, mixed> $decision
*/
private function entitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$planProfileLabel = $decision['plan_profile_label'] ?? null;
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
return sprintf('%s plan profile', $planProfileLabel);
}
return 'plan profile default';
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
@ -966,29 +600,6 @@ 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;
@ -1110,27 +721,6 @@ private function normalizedInputValues(): array
}
}
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
if (($normalizedValues[$valueField] ?? null) === null) {
$normalizedValues[$reasonField] = null;
continue;
}
if (($normalizedValues[$reasonField] ?? null) !== null) {
continue;
}
$message = match ($valueField) {
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
default => 'Override reason is required when an explicit override is set.',
};
$validationErrors['data.'.$reasonField] ??= [];
$validationErrors['data.'.$reasonField][] = $message;
}
return [$normalizedValues, $validationErrors];
}
@ -1394,9 +984,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
private function sourceLabel(string $source): string
{
return match ($source) {
'workspace_override' => __('localization.source.workspace_override'),
'workspace_override' => 'workspace override',
'tenant_override' => 'tenant override',
default => __('localization.source.system_default'),
default => 'system default',
};
}

View File

@ -11,44 +11,13 @@
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
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;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
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;
use Illuminate\Database\Eloquent\Model;
class TenantDashboard extends Dashboard
{
/**
* @var list<string>
*/
public array $supportDiagnosticsAuditKeys = [];
public function getTitle(): string
{
return __('localization.dashboard.tenant_title');
}
/**
* @param array<mixed> $parameters
*/
@ -77,346 +46,4 @@ public function getColumns(): int|array
{
return 2;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->requestSupportAction(),
$this->openSupportDiagnosticsAction(),
];
}
public function authorizeTenantSupportRequest(): void
{
$this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
}
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->color('gray')
->slideOver()
->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('included_context')
->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())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email),
])
->action(function (array $data): void {
$actor = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
Notification::make()
->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();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
->apply();
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
->label(__('localization.dashboard.open_support_diagnostics'))
->icon('heroicon-o-lifebuoy')
->color('gray')
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_diagnostics'))
->modalDescription(__('localization.dashboard.support_diagnostics_description'))
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen();
})
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
'bundle' => $this->tenantSupportDiagnosticBundle(),
]));
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
->apply();
}
/**
* @return array<string, mixed>
*/
public function tenantSupportDiagnosticBundle(): array
{
$user = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
}
private function auditTenantSupportDiagnosticsOpen(): void
{
$user = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
bundle: $this->tenantSupportDiagnosticBundle(),
user: $user,
);
}
/**
* @param array<string, mixed> $bundle
*/
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
{
$auditKey = 'tenant:'.$tenant->getKey();
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
return;
}
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
tenant: $tenant,
contextType: 'tenant',
bundle: $bundle,
actor: $user,
);
app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
workspaceId: (int) $tenant->workspace_id,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant',
subjectId: (int) $tenant->getKey(),
metadata: [
'source_surface' => 'tenant_dashboard',
],
);
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
private function resolveDashboardActor(): User
{
$user = auth()->user();
if (! $user instanceof User) {
abort(404);
}
return $user;
}
private function resolveCurrentTenantForCapability(string $capability): Tenant
{
$user = $this->resolveDashboardActor();
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, $capability)) {
abort(403);
}
return $tenant;
}
private function tenantSupportRequestAttachmentSummary(): string
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return 'Only canonical redacted tenant context will be attached.';
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return 'Only canonical redacted tenant context will be attached.';
}
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
? '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<string, string>
*/
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<string, mixed>|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,
]),
};
}
}

View File

@ -25,7 +25,6 @@
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
@ -458,7 +457,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value,
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -529,7 +528,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value,
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -756,7 +755,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value,
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
@ -853,7 +852,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BackupScheduleExecute->value,
type: 'backup_schedule_run',
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,

View File

@ -9,7 +9,6 @@
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
@ -32,8 +31,6 @@
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -843,17 +840,7 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
private static function profileNextStep(BaselineProfile $profile): string
{
$compareAvailabilityReason = self::compareAvailabilityReason($profile);
if ($compareAvailabilityReason === null) {
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
return $latestCaptureEnvelope->shortExplanation;
}
}
return match ($compareAvailabilityReason) {
return match (self::compareAvailabilityReason($profile)) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
@ -871,30 +858,6 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
}
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
{
$run = OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCapture->value))
->where('context->baseline_profile_id', (int) $profile->getKey())
->where('status', 'completed')
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
$reasonCode = data_get($run->context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
}
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
{
$status = $profile->status instanceof BaselineProfileStatus

View File

@ -17,10 +17,8 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
@ -107,10 +105,15 @@ private function captureAction(): Action
if (! $result['ok']) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
$message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
? trim($translation->shortExplanation)
: 'Reason: '.str_replace('.', ' ', $reasonCode);
$message = match ($reasonCode) {
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make()
->title('Cannot start capture')
@ -341,8 +344,8 @@ private function compareAssignedTenantsAction(): Action
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
: OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')

View File

@ -6,7 +6,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot;
@ -174,12 +173,9 @@ public static function infolist(Schema $schema): Schema
->label('Operation')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
->openUrlInNewTab()
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
->openUrlInNewTab(),
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
])
->columns(2),
Section::make('Summary')
@ -225,7 +221,6 @@ public static function infolist(Schema $schema): Schema
->label('Raw summary JSON')
->view('filament.infolists.entries.snapshot-json')
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
->columnSpanFull(),
])
->columns(4),
@ -240,7 +235,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
{
$entries = [];
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
if (is_numeric($record->operation_run_id)) {
$entries[] = RelatedContextEntry::available(
key: 'operation_run',
label: 'Operation',
@ -259,20 +254,12 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
->first();
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
$packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
if (static::isCustomerWorkspaceFlow()) {
$packUrl = static::appendQuery($packUrl, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
]);
}
$entries[] = RelatedContextEntry::available(
key: 'review_pack',
label: 'Review pack',
value: sprintf('#%d', (int) $pack->getKey()),
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
targetUrl: $packUrl,
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
targetKind: 'direct_record',
priority: 20,
actionLabel: 'View review pack',
@ -280,40 +267,9 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
)->toArray();
}
if ($record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'customer_review_workspace',
label: 'Customer workspace',
value: $record->tenant->name,
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
targetKind: 'canonical_page',
priority: 30,
actionLabel: 'Open customer workspace',
contextBadge: 'Reporting',
)->toArray();
}
return $entries;
}
public static function isCustomerWorkspaceFlow(): bool
{
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
}
/**
* @param array<string, mixed> $query
*/
private static function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
public static function table(Table $table): Table
{
return $table

View File

@ -5,13 +5,8 @@
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
@ -25,13 +20,6 @@ class ViewEvidenceSnapshot extends ViewRecord
{
protected static string $resource = EvidenceSnapshotResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceProofOpen();
}
protected function resolveRecord(int|string $key): Model
{
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
@ -39,10 +27,6 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array
{
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
return [];
}
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
@ -106,41 +90,4 @@ protected function getHeaderActions(): array
->apply(),
];
}
private function auditCustomerWorkspaceProofOpen(): void
{
if (! EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
return;
}
$record = $this->record;
$user = auth()->user();
if (! $record instanceof EvidenceSnapshot || ! $user instanceof User) {
return;
}
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::EvidenceSnapshotOpened,
context: [
'metadata' => [
'evidence_snapshot_id' => (int) $record->getKey(),
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
],
],
actor: $user,
resourceType: 'evidence_snapshot',
resourceId: (string) $record->getKey(),
targetLabel: sprintf('Evidence snapshot #%d', (int) $record->getKey()),
tenant: $tenant,
operationRunId: $record->operation_run_id !== null ? (int) $record->operation_run_id : null,
);
}
}

View File

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

View File

@ -6,12 +6,17 @@
use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Filament\Widgets\Tenant\FindingStatsOverview;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Services\OperationRunService;
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\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions;
@ -71,15 +76,15 @@ public function getTabs(): array
$stats = FindingResource::findingStatsForCurrentTenant();
return [
'all' => Tab::make(__('localization.findings.all'))
'all' => Tab::make('All')
->icon('heroicon-m-list-bullet'),
'needs_action' => Tab::make(__('localization.findings.needs_action'))
'needs_action' => Tab::make('Needs action')
->icon('heroicon-m-exclamation-triangle')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery()))
->badge($stats['open'] > 0 ? $stats['open'] : null)
->badgeColor('warning'),
'overdue' => Tab::make(__('localization.findings.overdue'))
'overdue' => Tab::make('Overdue')
->icon('heroicon-m-clock')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())
@ -87,11 +92,11 @@ public function getTabs(): array
->where('due_at', '<', now()))
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
->badgeColor('danger'),
'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
'risk_accepted' => Tab::make('Risk accepted')
->icon('heroicon-m-shield-check')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', Finding::STATUS_RISK_ACCEPTED)),
'resolved' => Tab::make(__('localization.findings.resolved'))
'resolved' => Tab::make('Resolved')
->icon('heroicon-m-archive-box')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
@ -102,6 +107,84 @@ protected function getHeaderActions(): array
{
$actions = [];
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
$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 (OperationRunService $operationRuns): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
abort(404);
}
$opRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: 'findings.lifecycle.backfill',
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'trigger' => 'backfill',
],
context: [
'workspace_id' => (int) $tenant->workspace_id,
'initiator_user_id' => (int) $user->getKey(),
],
initiator: $user,
);
$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;
}
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
BackfillFindingLifecycleJob::dispatch(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
});
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')
@ -171,6 +254,7 @@ protected function getHeaderActions(): array
if (! in_array((string) $finding->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;

View File

@ -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(__('localization.findings.open_approval_queue'))
->label('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(__('localization.findings.actions'))
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
]);

View File

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

View File

@ -15,7 +15,6 @@
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
@ -176,7 +175,7 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::InventorySync->value,
type: 'inventory_sync',
identityInputs: [
'selection_hash' => $computed['selection_hash'],
],

View File

@ -27,7 +27,6 @@
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer;
@ -231,9 +230,7 @@ public static function table(Table $table): Table
return $query;
}
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical(
OperationCatalog::canonicalCode($value),
));
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
}),
Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
@ -414,7 +411,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
);
}
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCompare->value) {
if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
@ -469,7 +466,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
}
}
if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCapture->value) {
if ((string) $record->type === 'baseline_capture') {
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
if ($baselineCaptureEvidence !== []) {
@ -1449,7 +1446,7 @@ private static function reconciliationPayload(OperationRun $record): array
*/
private static function inventorySyncCoverageSection(OperationRun $record): ?array
{
if (OperationCatalog::canonicalCode((string) $record->type) !== OperationRunType::InventorySync->value) {
if ((string) $record->type !== 'inventory_sync') {
return null;
}

View File

@ -20,15 +20,12 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -53,7 +50,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str;
use InvalidArgumentException;
use UnitEnum;
class ProviderConnectionResource extends Resource
@ -488,62 +484,6 @@ private static function verificationStatusLabelFromState(mixed $state): string
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
}
private static function targetScopeHelpText(): string
{
return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.';
}
private static function targetScopeSummary(?ProviderConnection $record): string
{
if (! $record instanceof ProviderConnection) {
return 'Target scope is set when this connection is saved.';
}
try {
return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary();
} catch (InvalidArgumentException) {
return 'Target scope needs review';
}
}
private static function providerIdentityContext(?ProviderConnection $record): ?string
{
if (! $record instanceof ProviderConnection) {
return null;
}
try {
return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine();
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array
{
try {
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra);
} catch (InvalidArgumentException) {
return array_merge([
'provider_connection_id' => (int) $record->getKey(),
'provider' => (string) $record->provider,
'target_scope' => [
'provider' => (string) $record->provider,
'scope_kind' => 'tenant',
'scope_identifier' => (string) $record->entra_tenant_id,
'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id),
'shared_label' => 'Target scope',
'shared_help_text' => static::targetScopeHelpText(),
],
'provider_identity_context' => [],
], $extra);
}
}
public static function form(Schema $schema): Schema
{
return $schema
@ -556,17 +496,11 @@ public static function form(Schema $schema): Schema
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Target scope ID')
->label('Entra tenant ID')
->required()
->maxLength(255)
->helperText(static::targetScopeHelpText())
->validationAttribute('target scope ID')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']),
Placeholder::make('target_scope_display')
->label('Target scope')
->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record))
->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection),
Placeholder::make('connection_type_display')
->label('Connection type')
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
@ -629,9 +563,8 @@ public static function infolist(Schema $schema): Schema
->label('Display name'),
Infolists\Components\TextEntry::make('provider')
->label('Provider'),
Infolists\Components\TextEntry::make('target_scope')
->label('Target scope')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
Infolists\Components\TextEntry::make('entra_tenant_id')
->label('Entra tenant ID')
->copyable(),
Infolists\Components\TextEntry::make('connection_type')
->label('Connection type')
@ -681,11 +614,6 @@ public static function infolist(Schema $schema): Schema
->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('provider_identity_context')
->label('Provider identity details')
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
->placeholder('n/a')
->columnSpanFull(),
Infolists\Components\TextEntry::make('last_error_reason_code')
->label('Last error reason')
->placeholder('n/a'),
@ -743,15 +671,9 @@ public static function table(Table $table): Table
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('provider')
->label('Provider')
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
Tables\Columns\TextColumn::make('target_scope')
->label('Target scope')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
->copyable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
Tables\Columns\TextColumn::make('connection_type')
->label('Connection type')
->badge()
@ -950,7 +872,7 @@ public static function makeInventorySyncAction(): Actions\Action
static::handleProviderOperationAction(
record: $record,
gate: $gate,
operationType: OperationRunType::InventorySync->value,
operationType: 'inventory_sync',
blockedTitle: 'Inventory sync blocked',
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
ProviderInventorySyncJob::dispatch(
@ -1027,7 +949,10 @@ public static function makeSetDefaultAction(): Actions\Action
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => static::targetScopeAuditMetadata($record),
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1089,12 +1014,15 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => static::targetScopeAuditMetadata($record, [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'],
'source' => $source,
]),
],
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1233,11 +1161,14 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => static::targetScopeAuditMetadata($record, [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => $source,
]),
],
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1302,12 +1233,14 @@ public static function makeEnableConnectionAction(): Actions\Action
tenant: $tenant,
action: 'provider_connection.enabled',
context: [
'metadata' => static::targetScopeAuditMetadata($record, [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials,
]),
],
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1369,10 +1302,12 @@ public static function makeDisableConnectionAction(): Actions\Action
tenant: $tenant,
action: 'provider_connection.disabled',
context: [
'metadata' => static::targetScopeAuditMetadata($record, [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
]),
],
],
actorId: $actorId,
actorEmail: $actorEmail,

View File

@ -9,12 +9,9 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
class CreateProviderConnection extends CreateRecord
{
@ -31,21 +28,6 @@ protected function mutateFormDataBeforeCreate(array $data): array
}
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
scopeDisplayName: (string) ($data['display_name'] ?? ''),
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'entra_tenant_id' => $targetScope['message'],
]);
}
return [
'workspace_id' => (int) $tenant->workspace_id,
@ -88,9 +70,11 @@ protected function afterCreate(): void
tenant: $tenant,
action: 'provider_connection.created',
context: [
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'connection_type' => $record->connection_type->value,
]),
],
],
actorId: $actorId,
actorEmail: $actorEmail,

View File

@ -19,8 +19,6 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
@ -28,7 +26,6 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class EditProviderConnection extends EditRecord
{
@ -80,22 +77,6 @@ protected function mutateFormDataBeforeSave(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
unset($data['is_default']);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
scopeDisplayName: (string) ($data['display_name'] ?? ''),
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'entra_tenant_id' => $targetScope['message'],
]);
}
return $data;
}
@ -138,9 +119,11 @@ protected function afterSave(): void
tenant: $tenant,
action: 'provider_connection.updated',
context: [
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
]),
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'fields' => $changedFields,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -156,7 +139,10 @@ protected function afterSave(): void
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,

View File

@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
public function getTableEmptyStateHeading(): ?string
{
return 'No provider connections found';
return 'No Microsoft connections found';
}
public function getTableEmptyStateDescription(): ?string
{
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
}
public function getTableEmptyStateActions(): array

View File

@ -18,9 +18,7 @@
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Rules\SkipOrUuidRule;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger;
@ -33,18 +31,14 @@
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus;
@ -1927,26 +1921,16 @@ public static function createRestoreRun(array $data): RestoreRun
->executionSafetySnapshot($tenant, $user, $data)
->toArray();
try {
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
app(ProviderOperationStartResultPresenter::class)
->notification(
@ -1994,13 +1978,6 @@ private static function startQueuedRestoreExecution(
$initiator = auth()->user();
$initiator = $initiator instanceof User ? $initiator : null;
static::guardRestoreExecutionOperationalControl(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
initiator: $initiator,
);
$queuedRestoreRun = null;
$dispatcher = function (OperationRun $run) use (
@ -2120,58 +2097,6 @@ private static function startQueuedRestoreExecution(
return [$result, $queuedRestoreRun?->refresh()];
}
/**
* @param array<int>|null $selectedItemIds
*/
private static function guardRestoreExecutionOperationalControl(
Tenant $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
?User $initiator,
): void {
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Restore execution requires a workspace context.');
}
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
if (! $decision->isPaused()) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: [
'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?->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null,
'requested_scope' => 'restore.execute',
], static fn (mixed $value): bool => $value !== null && $value !== ''),
],
actor: $initiator,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: OperationCatalog::label('restore.execute'),
summary: 'Restore execution blocked by operational control',
tenant: $tenant,
);
throw OperationalControlBlockedException::forDecision(
decision: $decision,
actionLabel: OperationCatalog::label('restore.execute'),
);
}
/**
* @param array<int>|null $selectedItemIds
*/
@ -2604,26 +2529,16 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
$metadata['rerun_of_restore_run_id'] = $record->id;
try {
[$result, $newRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
return;
}
[$result, $newRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);

View File

@ -2,10 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack;
@ -13,11 +10,9 @@
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus;
@ -49,8 +44,6 @@
class ReviewPackResource extends Resource
{
use ResolvesPanelTenantContext;
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -108,9 +101,9 @@ public static function canView(Model $record): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
@ -148,8 +141,7 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('file_size')
->label('File size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
@ -185,7 +177,6 @@ public static function infolist(Schema $schema): Schema
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
])
->columns(2)
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
->columnSpanFull(),
Section::make('Metadata')
@ -198,13 +189,6 @@ public static function infolist(Schema $schema): Schema
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
: null)
->placeholder('—'),
TextEntry::make('customer_workspace')
->label('Customer workspace')
->state(fn (): string => 'Open workspace')
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
: null)
->placeholder('—'),
TextEntry::make('summary.review_status')
->label('Review status')
->badge()
@ -215,26 +199,13 @@ public static function infolist(Schema $schema): Schema
->placeholder('—'),
TextEntry::make('operationRun.id')
->label('Operation')
->url(function (ReviewPack $record): ?string {
if (! $record->operation_run_id) {
return null;
}
$tenant = $record->tenant;
if ($tenant instanceof Tenant) {
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
}
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
})
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
: null)
->openUrlInNewTab()
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
->placeholder('—'),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
TextEntry::make('created_at')->label('Created')->dateTime(),
])
->columns(2)
@ -248,7 +219,9 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('evidenceSnapshot.id')
->label('Snapshot')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null),
TextEntry::make('evidenceSnapshot.completeness_state')
->label('Snapshot completeness')
->badge()
@ -366,62 +339,41 @@ public static function table(Table $table): Table
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
static::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
]);
}
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
{
$action = UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
->icon('heroicon-o-plus')
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
->action(function (array $data): void {
static::executeGeneration($data);
})
->form(static::reviewPackGenerationFormSchema())
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
return $action;
}
/**
* @return array<int, Section>
*/
public static function reviewPackGenerationFormSchema(): array
{
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
];
}
public static function getEloquentQuery(): Builder
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
->where('tenant_id', (int) $tenant->getKey());
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
@ -432,36 +384,6 @@ public static function getPages(): array
];
}
public static function isCustomerWorkspaceFlow(): bool
{
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
}
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
{
if (! $record->evidenceSnapshot) {
return null;
}
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
return static::isCustomerWorkspaceFlow()
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
: $url;
}
/**
* @param array<string, mixed> $query
*/
private static function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
@ -525,14 +447,6 @@ public static function executeGeneration(array $data): void
try {
$reviewPack = $service->generate($tenant, $user, $options);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->warning()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->send();
return;
} catch (ReviewPackEvidenceResolutionException $exception) {
$reasons = $exception->result->reasons;
@ -568,69 +482,4 @@ public static function executeGeneration(array $data): void
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function currentTenantContext(): ?Tenant
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
return $tenant instanceof Tenant ? $tenant : null;
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? 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();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
}
}

View File

@ -3,7 +3,12 @@
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Section;
class ListReviewPacks extends ListRecords
{
@ -12,13 +17,29 @@ class ListReviewPacks extends ListRecords
protected function getHeaderActions(): array
{
return [
ReviewPackResource::generatePackAction()
->visible(fn (): bool => $this->tableHasRecords()),
UiEnforcement::forAction(
Actions\Action::make('generate_pack')
->label('Generate Pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
ReviewPackResource::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
}

View File

@ -19,65 +19,6 @@ class ViewReviewPack extends ViewRecord
protected function getHeaderActions(): array
{
if (ReviewPackResource::isCustomerWorkspaceFlow()) {
return [
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE,
]))
->openUrlInNewTab(),
];
}
$regenerateAction = UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
/** @var ReviewPack $record */
$record = $this->record;
$options = array_merge($record->options ?? [], [
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
]);
ReviewPackResource::executeGeneration($options);
})
->form(function (): array {
/** @var ReviewPack $record */
$record = $this->record;
$currentOptions = $record->options ?? [];
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default((bool) ($currentOptions['include_pii'] ?? true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default((bool) ($currentOptions['include_operations'] ?? true)),
]),
];
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
return [
Actions\Action::make('download')
->label('Download')
@ -87,7 +28,46 @@ protected function getHeaderActions(): array
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
$regenerateAction,
UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
/** @var ReviewPack $record */
$record = $this->record;
$options = array_merge($record->options ?? [], [
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
]);
ReviewPackResource::executeGeneration($options);
})
->form(function (): array {
/** @var ReviewPack $record */
$record = $this->record;
$currentOptions = $record->options ?? [];
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default((bool) ($currentOptions['include_pii'] ?? true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default((bool) ($currentOptions['include_operations'] ?? true)),
]),
];
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
];
}
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
@ -16,11 +15,9 @@
use App\Models\TenantOnboardingSession;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
@ -47,7 +44,6 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
@ -72,7 +68,6 @@
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -829,27 +824,6 @@ public static function table(Table $table): Table
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('compareTenants')
->label('Compare tenants')
->icon('heroicon-o-scale')
->color('gray')
->url(function (Tenant $record, mixed $livewire): string {
$triageState = $livewire instanceof Pages\ListTenants
? static::currentPortfolioTriageState($livewire)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
}
return static::crossTenantCompareOpenUrl($record, $triageState);
})
->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
@ -992,34 +966,6 @@ public static function table(Table $table): Table
])
->bulkActions([
BulkActionGroup::make([
Actions\BulkAction::make('compareSelected')
->label('Compare selected')
->icon('heroicon-o-scale')
->color('gray')
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->extraAttributes(fn (mixed $livewire): array => [
'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null',
'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire),
'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire),
'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }',
])
->action(function (Collection $records, mixed $livewire): void {
$disabledReason = static::crossTenantCompareBulkDisabledReason($records);
if ($disabledReason !== null) {
Notification::make()
->title($disabledReason)
->danger()
->send();
return;
}
if (method_exists($livewire, 'redirect')) {
$livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true);
}
}),
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
@ -1212,52 +1158,6 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
);
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string
{
return static::crossTenantCompareOpenUrlForSelection(
targetTenant: $record,
triageState: $triageState,
);
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
public static function crossTenantCompareOpenUrlForSelection(
Tenant $targetTenant,
array $triageState = [],
?Tenant $sourceTenant = null,
): string {
$normalizedState = static::portfolioReturnFilters(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
);
return CrossTenantComparePage::launchUrl(
sourceTenant: $sourceTenant,
targetTenant: $targetTenant,
navigationContext: CanonicalNavigationContext::forTenantRegistry(
backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState),
tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(),
),
);
}
/**
* @param array{
* backup_posture?: list<string>,
@ -1348,168 +1248,6 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
);
}
private static function crossTenantCompareActionVisible(Tenant $record): bool
{
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $workspaceResolver */
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
if (! $workspaceResolver->isMember($user, $workspace)
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
return false;
}
/** @var CapabilityResolver $tenantResolver */
$tenantResolver = app(CapabilityResolver::class);
return $user->canAccessTenant($record)
&& $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW);
}
private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string
{
$user = auth()->user();
if (! $user instanceof User) {
return UiTooltips::insufficientPermission();
}
$tenants = $records
->filter(fn ($record): bool => $record instanceof Tenant)
->values();
if ($records->count() !== 2 || $tenants->count() !== 2) {
return 'Select exactly two tenants to compare.';
}
if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) {
return 'Only active tenants can be compared.';
}
$workspaceIds = $tenants
->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id)
->unique()
->values();
if ($workspaceIds->count() !== 1) {
return UiTooltips::insufficientPermission();
}
$workspaceId = $workspaceIds->first();
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return UiTooltips::insufficientPermission();
}
/** @var WorkspaceCapabilityResolver $workspaceResolver */
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
if (! $workspaceResolver->isMember($user, $workspace)
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
return UiTooltips::insufficientPermission();
}
/** @var CapabilityResolver $tenantResolver */
$tenantResolver = app(CapabilityResolver::class);
$isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant)
|| ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW));
return $isDenied ? UiTooltips::insufficientPermission() : null;
}
private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string
{
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}";
}
private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string
{
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)";
}
private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string
{
$inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire));
return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))";
}
/**
* @return list<string>
*/
private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array
{
if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) {
return [];
}
$tableRecords = $livewire->getTableRecords();
if (method_exists($tableRecords, 'getCollection')) {
$tableRecords = $tableRecords->getCollection();
}
return collect($tableRecords)
->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive())
->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant))
->values()
->all();
}
private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string
{
$triageState = $livewire instanceof Pages\ListTenants
? static::currentPortfolioTriageState($livewire)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
}
$tenants = $records
->filter(fn ($record): bool => $record instanceof Tenant)
->values();
return static::crossTenantCompareOpenUrlForSelection(
targetTenant: $tenants->get(1),
triageState: $triageState,
sourceTenant: $tenants->get(0),
);
}
private static function hasActivePortfolioTriageState(
array $backupPostures,
array $recoveryEvidence,

View File

@ -6,9 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
@ -17,11 +15,9 @@
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
@ -85,26 +81,6 @@ 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();
@ -173,7 +149,7 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make(__('localization.review.outcome_summary'))
Section::make('Outcome summary')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
@ -182,7 +158,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(),
])
->columnSpanFull(),
Section::make(__('localization.review.review'))
Section::make('Review')
->schema([
TextEntry::make('status')
->badge()
@ -191,23 +167,23 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextEntry::make('completeness_state')
->label(__('localization.review.completeness'))
->label('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(__('localization.review.tenant')),
TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('published_at')->dateTime()->placeholder('—'),
TextEntry::make('evidenceSnapshot.id')
->label(__('localization.review.evidence_snapshot'))
->label('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(__('localization.review.current_export'))
->label('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)
@ -215,14 +191,13 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('fingerprint')
->copyable()
->placeholder('—')
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
->columnSpanFull()
->fontFamily('mono')
->size(TextSize::ExtraSmall),
])
->columns(2)
->columnSpanFull(),
Section::make(__('localization.review.executive_posture'))
Section::make('Executive posture')
->schema([
ViewEntry::make('review_summary')
->hiddenLabel()
@ -231,21 +206,21 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(),
])
->columnSpanFull(),
Section::make(__('localization.review.sections'))
Section::make('Sections')
->schema([
RepeatableEntry::make('sections')
->hiddenLabel()
->schema([
TextEntry::make('title'),
TextEntry::make('completeness_state')
->label(__('localization.review.completeness'))
->label('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(__('localization.review.details'))
Section::make('Details')
->schema([
ViewEntry::make('section_payload')
->hiddenLabel()
@ -265,25 +240,6 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('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,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
return $table
->defaultSort('generated_at', 'desc')
->persistFiltersInSession()
@ -299,7 +255,7 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(),
Tables\Columns\TextColumn::make('outcome')
->label(__('localization.review.outcome'))
->label('Outcome')
->badge()
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
@ -310,10 +266,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(__('localization.review.export'))
->label('Export')
->boolean(),
Tables\Columns\TextColumn::make('next_step')
->label(__('localization.review.next_step'))
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
->wrap(),
Tables\Columns\TextColumn::make('fingerprint')
@ -327,18 +283,31 @@ 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', __('localization.review.review_date'), 'generated_at'),
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
])
->actions([
$exportExecutivePackAction,
UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
->bulkActions([])
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
->emptyStateDescription(__('localization.review.create_first_review_description'))
->emptyStateHeading('No tenant reviews yet')
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
->emptyStateActions([
static::makeCreateReviewAction(
name: 'create_first_review',
label: __('localization.review.create_first_review'),
label: 'Create first review',
icon: 'heroicon-o-plus',
),
]);
@ -357,23 +326,19 @@ 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(__('localization.review.evidence_basis'))
Section::make('Evidence basis')
->schema([
Select::make('evidence_snapshot_id')
->label(__('localization.review.evidence_snapshot'))
->label('Evidence snapshot')
->required()
->options(fn (): array => static::evidenceSnapshotOptions())
->searchable()
->helperText(__('localization.review.evidence_basis_helper')),
->helperText('Choose the anchored evidence snapshot for this review.'),
]),
])
->action(fn (array $data): mixed => static::executeCreateReview($data)),
@ -391,7 +356,7 @@ public static function executeCreateReview(array $data): void
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
return;
}
@ -413,7 +378,7 @@ public static function executeCreateReview(array $data): void
: null;
if (! $snapshot instanceof EvidenceSnapshot) {
Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
return;
}
@ -421,7 +386,7 @@ public static function executeCreateReview(array $data): void
try {
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
return;
}
@ -431,11 +396,11 @@ public static function executeCreateReview(array $data): void
if (! $review->wasRecentlyCreated) {
Notification::make()
->success()
->title(__('localization.review.review_already_available'))
->body(__('localization.review.review_already_available_body'))
->title('Review already available')
->body('A matching mutable review already exists for this evidence basis.')
->actions([
Actions\Action::make('view_review')
->label(__('localization.review.view_review'))
->label('View review')
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
])
->send();
@ -444,12 +409,12 @@ public static function executeCreateReview(array $data): void
}
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
->body(__('localization.review.review_composing_background'));
->body('The review is being composed in the background.');
if ($review->operation_run_id) {
$toast->actions([
Actions\Action::make('view_run')
->label(__('localization.review.open_operation'))
->label('Open operation')
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]);
}
@ -457,71 +422,13 @@ public static function executeCreateReview(array $data): void
$toast->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? 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();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
}
public static function executeExport(TenantReview $review): void
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
$user = auth()->user();
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
return;
}
@ -538,7 +445,7 @@ public static function executeExport(TenantReview $review): void
if ($service->checkActiveRunForReview($review)) {
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
->body(__('localization.review.export_already_queued_body'))
->body('An executive pack export is already queued or running for this review.')
->send();
return;
@ -549,12 +456,8 @@ public static function executeExport(TenantReview $review): void
'include_pii' => true,
'include_operations' => true,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
return;
} catch (\Throwable $throwable) {
Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
return;
}
@ -565,11 +468,11 @@ public static function executeExport(TenantReview $review): void
if (! $pack->wasRecentlyCreated) {
Notification::make()
->success()
->title(__('localization.review.executive_pack_already_available'))
->body(__('localization.review.executive_pack_already_available_body'))
->title('Executive pack already available')
->body('A matching executive pack already exists for this review.')
->actions([
Actions\Action::make('view_pack')
->label(__('localization.review.view_pack'))
->label('View pack')
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
])
->send();
@ -578,7 +481,7 @@ public static function executeExport(TenantReview $review): void
}
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body(__('localization.review.executive_pack_generating_background'))
->body('The executive pack is being generated in the background.')
->send();
}
@ -618,7 +521,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') ?? __('localization.review.pending')
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
),
])
->all();
@ -637,94 +540,55 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : [];
$truthEnvelope = static::truthEnvelope($record);
$reasonPresenter = app(ReasonPresenter::class);
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) {
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
}
return [
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
'customer_workspace_mode' => static::isCustomerWorkspaceMode(),
'reason_semantics' => static::isCustomerWorkspaceMode()
? []
: $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => $highlights,
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
'metrics' => static::isCustomerWorkspaceMode() ? [
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_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)],
] : [
['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)],
'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)],
],
];
}
/**
* @return array<int, array{title:string,label:string,url:?string,description:string}>
* @return array<int, array{title:string,label:string,url:string,description:string}>
*/
private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array
private static function summaryContextLinks(TenantReview $record): array
{
$links = [];
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
if (is_numeric($record->operation_run_id)) {
$links[] = [
'title' => __('localization.review.operation'),
'label' => __('localization.review.open_operation'),
'title' => 'Operation',
'label' => 'Open operation',
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => __('localization.review.operation_description'),
'description' => 'Inspect the latest review composition or refresh run.',
];
}
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [
'title' => __('localization.review.executive_pack'),
'label' => __('localization.review.view_executive_pack'),
'title' => 'Executive pack',
'label' => 'View executive pack',
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => __('localization.review.executive_pack_description'),
];
}
if ($record->tenant) {
$links[] = [
'title' => __('localization.review.customer_workspace'),
'label' => __('localization.review.open_customer_workspace'),
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => __('localization.review.customer_workspace_description'),
'description' => 'Open the current export that belongs to this review.',
];
}
if ($record->evidenceSnapshot && $record->tenant) {
$user = auth()->user();
$canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant);
$evidenceUrl = $canViewEvidence
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null;
if ($customerWorkspaceMode && $evidenceUrl !== null) {
$evidenceUrl = static::appendQuery($evidenceUrl, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
]);
}
$links[] = [
'title' => __('localization.review.evidence_snapshot'),
'label' => __('localization.review.view_evidence_snapshot'),
'url' => $evidenceUrl,
'description' => $canViewEvidence
? __('localization.review.evidence_snapshot_description')
: __('localization.review.evidence_proof_access_unavailable'),
'title' => 'Evidence snapshot',
'label' => 'View evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => 'Return to the evidence basis behind this review.',
];
}
@ -791,35 +655,4 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa
SurfaceCompressionContext::tenantReview(),
);
}
/**
* @param array<string, mixed> $summary
*/
private static function findingOutcomeSummary(array $summary): ?string
{
$outcomeCounts = $summary['finding_outcomes'] ?? [];
if (! is_array($outcomeCounts)) {
return null;
}
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
}
private static function isCustomerWorkspaceMode(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
/**
* @param array<string, mixed> $query
*/
private static function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -4,20 +4,14 @@
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\ReviewPackService;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus;
use App\Support\TenantReviewStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions;
@ -30,13 +24,6 @@ class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceOpen();
}
protected function resolveRecord(int|string $key): Model
{
return TenantReviewResource::resolveScopedRecordOrFail($key);
@ -67,12 +54,6 @@ protected function authorizeAccess(): void
protected function getHeaderActions(): array
{
if ($this->isCustomerWorkspaceView()) {
return [
$this->downloadCurrentReviewPackAction(),
];
}
$secondaryActions = $this->secondaryLifecycleActions();
return array_values(array_filter([
@ -88,7 +69,7 @@ protected function getHeaderActions(): array
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
]));
}
@ -104,10 +85,6 @@ private function primaryLifecycleAction(): ?Actions\Action
private function primaryLifecycleActionName(): ?string
{
if ($this->isCustomerWorkspaceView()) {
return null;
}
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
@ -145,10 +122,6 @@ private function secondaryLifecycleActions(): array
*/
private function secondaryLifecycleActionNames(): array
{
if ($this->isCustomerWorkspaceView()) {
return [];
}
$names = [];
if ($this->record->isMutable()) {
@ -205,6 +178,7 @@ private function refreshReviewAction(): Actions\Action
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
@ -258,7 +232,7 @@ private function publishReviewAction(): Actions\Action
private function exportExecutivePackAction(): Actions\Action
{
$action = UiEnforcement::forAction(
return UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
@ -267,17 +241,11 @@ private function exportExecutivePackAction(): Actions\Action
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
return $action;
}
private function createNextReviewAction(): Actions\Action
@ -351,107 +319,4 @@ private function archiveReviewAction(): Actions\Action
->preserveVisibility()
->apply();
}
private function downloadCurrentReviewPackAction(): Actions\Action
{
return Actions\Action::make('download_current_review_pack')
->label(__('localization.review.download_current_review_pack'))
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
->openUrlInNewTab();
}
private function currentReviewPackDownloadUrl(): ?string
{
$pack = $this->record->currentExportReviewPack;
$tenant = $this->record->tenant;
$user = auth()->user();
if (! $pack instanceof ReviewPack || ! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
]);
}
private function currentReviewPackUnavailableReason(): ?string
{
if ($this->currentReviewPackDownloadUrl() !== null) {
return null;
}
$pack = $this->record->currentExportReviewPack;
$tenant = $this->record->tenant;
$user = auth()->user();
if (! $pack instanceof ReviewPack) {
return __('localization.review.customer_review_pack_missing');
}
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return __('localization.review.customer_review_pack_forbidden');
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return __('localization.review.customer_review_pack_not_ready');
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return __('localization.review.customer_review_pack_expired');
}
return __('localization.review.customer_review_pack_unavailable');
}
private function isCustomerWorkspaceView(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
private function auditCustomerWorkspaceOpen(): void
{
if (! $this->isCustomerWorkspaceView()) {
return;
}
$user = auth()->user();
$tenant = $this->record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::TenantReviewOpened,
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $this->record->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
tenant: $tenant,
);
}
}

View File

@ -6,9 +6,6 @@
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
use App\Filament\System\Widgets\ControlTowerKpis;
use App\Filament\System\Widgets\CustomerHealthKpis;
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Filament\System\Widgets\ControlTowerRecentFailures;
use App\Filament\System\Widgets\ControlTowerTopOffenders;
use App\Models\PlatformUser;
@ -28,11 +25,6 @@ class Dashboard extends BaseDashboard
{
public string $window = SystemConsoleWindow::LastDay;
public function getTitle(): string
{
return __('localization.dashboard.system_title');
}
/**
* @param array<mixed> $parameters
*/
@ -69,24 +61,9 @@ public function getWidgets(): array
{
return [
ControlTowerHealthIndicator::class,
new WidgetConfiguration(CustomerHealthKpis::class, [
'window' => $this->window,
]),
new WidgetConfiguration(CustomerHealthTopWorkspaces::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ControlTowerKpis::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ProductTelemetryKpis::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ControlTowerTopOffenders::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ControlTowerRecentFailures::class, [
'window' => $this->window,
]),
ControlTowerKpis::class,
ControlTowerTopOffenders::class,
ControlTowerRecentFailures::class,
];
}
@ -114,12 +91,12 @@ protected function getHeaderActions(): array
return [
Action::make('set_window')
->label(__('localization.dashboard.time_window'))
->label('Time window')
->icon('heroicon-o-clock')
->color('gray')
->form([
Select::make('window')
->label(__('localization.dashboard.window'))
->label('Window')
->options(SystemConsoleWindow::options())
->default($this->window)
->required(),
@ -135,7 +112,7 @@ protected function getHeaderActions(): array
}),
Action::make('enter_break_glass')
->label(__('localization.dashboard.enter_break_glass'))
->label('Enter break-glass mode')
->color('danger')
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
->requiresConfirmation()
@ -163,13 +140,13 @@ protected function getHeaderActions(): array
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
Notification::make()
->title(__('localization.dashboard.recovery_mode_enabled'))
->title('Recovery mode enabled')
->success()
->send();
}),
Action::make('exit_break_glass')
->label(__('localization.dashboard.exit_break_glass'))
->label('Exit break-glass')
->color('gray')
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
->requiresConfirmation()
@ -185,7 +162,7 @@ protected function getHeaderActions(): array
$breakGlass->exit($user);
Notification::make()
->title(__('localization.dashboard.recovery_mode_ended'))
->title('Recovery mode ended')
->success()
->send();
}),

View File

@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Directory\Concerns;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
use App\Support\SystemConsole\SystemConsoleWindow;
trait BuildsCustomerHealthDecisionData
{
/**
* @param array{
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>
* } $summary
* @return array{
* overall: array{label: string, color: string, icon: string|null},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* }
*/
protected function buildCustomerHealthDecision(array $summary, SystemConsoleWindow $window, string $surface): array
{
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
$dominantDimensions = collect($summary['dominant_dimension_keys'])
->map(function (string $dimensionKey) use ($summary): ?array {
$dimension = $summary['dimensions'][$dimensionKey] ?? null;
if (! is_array($dimension)) {
return null;
}
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
return [
'label' => $dimension['label'],
'color' => $badge->color,
'icon' => $badge->icon,
];
})
->filter()
->take(2)
->values()
->all();
$dominantLabels = array_map(static fn (array $dimension): string => $dimension['label'], $dominantDimensions);
$primaryDimension = $summary['dominant_dimension_keys'][0] ?? null;
return [
'overall' => [
'label' => $overallBadge->label,
'color' => $overallBadge->color,
'icon' => $overallBadge->icon,
],
'reason' => $this->customerHealthReason($dominantLabels),
'impact' => $this->customerHealthImpact($summary['overall_level'], $primaryDimension),
'recommended_action' => $this->customerHealthRecommendedAction($summary['overall_level'], $primaryDimension, $surface),
'dominant_dimensions' => $dominantDimensions,
'window_label' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
];
}
/**
* @param list<string> $dominantLabels
*/
protected function customerHealthReason(array $dominantLabels): string
{
if ($dominantLabels === []) {
return 'No active health drivers are pressuring this workspace right now.';
}
$labelPrefix = count($dominantLabels) === 1 ? 'Top driver' : 'Top drivers';
return $labelPrefix.': '.implode(', ', $dominantLabels);
}
protected function customerHealthImpact(string $overallLevel, ?string $primaryDimension): string
{
if ($overallLevel === 'ok') {
return 'Tracked onboarding, provider, operational, governance, review-pack, and engagement signals are currently stable.';
}
if ($overallLevel === 'unknown') {
return 'Some required health truth is missing or stale, so this workspace cannot be treated as healthy yet.';
}
return match ($primaryDimension) {
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $overallLevel === 'critical'
? 'Onboarding readiness is blocked, so this workspace cannot be treated as operationally ready.'
: 'Onboarding readiness still needs follow-up before this workspace can be treated as fully stable.',
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $overallLevel === 'critical'
? 'Default provider consent or verification is blocking reliable tenant management.'
: 'Provider connectivity has degraded and may impact reliable tenant management.',
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $overallLevel === 'critical'
? 'Failed or stuck operations are actively putting delivery at risk.'
: 'Recent operational noise is starting to erode delivery confidence.',
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $overallLevel === 'critical'
? 'High-severity or expired governance pressure needs immediate review.'
: 'Governance pressure is active and should be reviewed before it escalates.',
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $overallLevel === 'critical'
? 'Recent review-pack work is unusable or expired, so review readiness is blocked.'
: 'Review-pack readiness is incomplete, so recent review evidence may not be usable yet.',
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $overallLevel === 'critical'
? 'Recent product activity is missing, which suggests active usage may be deteriorating.'
: 'Recent product activity is thinning out and may indicate adoption drift.',
default => $overallLevel === 'critical'
? 'This workspace needs immediate operator follow-up.'
: 'This workspace needs follow-up soon to prevent further drift.',
};
}
protected function customerHealthRecommendedAction(string $overallLevel, ?string $primaryDimension, string $surface): string
{
if ($overallLevel === 'ok') {
return 'Continue normal monitoring from the system dashboard.';
}
return match ($primaryDimension) {
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $surface === 'tenant'
? 'Confirm the tenant onboarding state with the responsible tenant admin and clear the blocking step.'
: 'Open the affected tenant below and confirm which onboarding step is blocked.',
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $surface === 'tenant'
? 'Review connectivity signals below and confirm the default provider consent and verification state.'
: 'Open the affected tenant below and review the default provider connection state.',
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Review recent operations below and triage failed or stuck runs first.',
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $surface === 'tenant'
? 'Review governance findings or exception pressure for this tenant before proceeding.'
: 'Open the affected tenant below and review governance findings or exceptions.',
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Check recent review-pack activity and confirm that a usable pack exists for the current window.',
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $surface === 'tenant'
? 'Confirm whether missing recent product activity is expected for this tenant.'
: 'Confirm whether missing recent product activity is expected across this workspace.',
default => 'Review the diagnostics below to confirm which source truth needs operator follow-up.',
};
}
}

View File

@ -4,25 +4,20 @@
namespace App\Filament\System\Pages\Directory;
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
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\Pages\Page;
use Illuminate\Support\Collection;
class ViewTenant extends Page
{
use BuildsCustomerHealthDecisionData;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directory/tenants/{tenant}';
@ -107,26 +102,4 @@ public function runsUrl(): string
{
return SystemOperationRunLinks::index();
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* }|null
*/
public function customerHealthDecision(): ?array
{
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace((int) $this->tenant->workspace_id, $window);
if (! is_array($summary)) {
return null;
}
return $this->buildCustomerHealthDecision($summary, $window, 'tenant');
}
}

View File

@ -4,31 +4,19 @@
namespace App\Filament\System\Pages\Directory;
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
use App\Models\OperationRun;
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;
class ViewWorkspace extends Page
{
use BuildsCustomerHealthDecisionData;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directory/workspaces/{workspace}';
@ -91,105 +79,4 @@ public function runsUrl(): string
{
return SystemOperationRunLinks::index();
}
/**
* @return array<string, mixed>
*/
public function workspaceEntitlementSummary(): array
{
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
}
/**
* @return array<string, mixed>
*/
public function workspaceCommercialLifecycleSummary(): array
{
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
}
/**
* @return array<Action>
*/
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},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* }|null
*/
public function customerHealthDecision(): ?array
{
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace($this->workspace, $window);
if (! is_array($summary)) {
return null;
}
return $this->buildCustomerHealthDecision($summary, $window, 'workspace');
}
}

View File

@ -1,688 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Audit\AuditRecorder;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationalControls\OperationalControlCatalog;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
class Controls extends Page
{
protected static ?string $navigationLabel = 'Controls';
protected static ?string $title = 'Operational Controls';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-pause-circle';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/controls';
protected string $view = 'filament.system.pages.ops.controls';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
}
public function getHeader(): ?View
{
return view('filament.system.pages.ops.partials.controls-header', [
'breadcrumbs' => filament()->hasBreadcrumbs() ? $this->getBreadcrumbs() : [],
'heading' => $this->getHeading(),
'subheading' => $this->getSubheading(),
]);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->pauseRestoreExecuteAction(),
$this->resumeRestoreExecuteAction(),
$this->viewHistoryRestoreExecuteAction(),
$this->pauseAiExecutionAction(),
$this->resumeAiExecutionAction(),
$this->viewHistoryAiExecutionAction(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function controlCards(): array
{
$catalog = app(OperationalControlCatalog::class);
return array_map(
fn (string $controlKey): array => $this->controlSummary($controlKey),
$catalog->keys(),
);
}
/**
* @return array<string, mixed>
*/
public function controlSummary(string $controlKey): array
{
$definition = app(OperationalControlCatalog::class)->definition($controlKey);
$activations = $this->activeActivationsForControl($controlKey);
$effectiveState = $activations->isEmpty() ? 'enabled' : 'paused';
$stateLabel = match (true) {
$activations->contains(fn (OperationalControlActivation $activation): bool => $activation->scope_type === 'global') => 'Paused globally',
$activations->isNotEmpty() => sprintf('Workspace pauses active (%d)', $activations->where('scope_type', 'workspace')->count()),
default => 'Enabled',
};
return [
'control_key' => $controlKey,
'action_slug' => $this->actionSlug($controlKey),
'label' => (string) $definition['label'],
'effective_state' => $effectiveState,
'state_label' => $stateLabel,
'supported_scopes' => $definition['supported_scopes'],
'affected_surfaces' => $definition['affected_surfaces'],
'active_activations' => $activations
->map(fn (OperationalControlActivation $activation): array => $this->activationSummary($activation))
->values()
->all(),
'history_count' => $this->recentAuditEventsForControl($controlKey)->count(),
];
}
/**
* @return array{control_key: string, scope_type: string, workspace_id: ?int, workspace_count: int, tenant_count: int, summary: string}
*/
public function scopeImpactPreview(string $controlKey, string $scopeType, ?int $workspaceId): array
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
if ($scopeType === 'workspace') {
$workspace = is_int($workspaceId)
? Workspace::query()->whereKey($workspaceId)->first()
: null;
if (! $workspace instanceof Workspace) {
return [
'control_key' => $controlKey,
'scope_type' => $scopeType,
'workspace_id' => null,
'workspace_count' => 0,
'tenant_count' => 0,
'summary' => 'Select a workspace to preview the scope impact.',
];
}
$tenantCount = Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->where('external_id', '!=', 'platform')
->count();
return [
'control_key' => $controlKey,
'scope_type' => $scopeType,
'workspace_id' => (int) $workspace->getKey(),
'workspace_count' => 1,
'tenant_count' => $tenantCount,
'summary' => sprintf('%s will affect workspace %s and %d %s.', $label, $workspace->name, $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
];
}
$tenantCount = Tenant::query()
->where('external_id', '!=', 'platform')
->count();
$workspaceCount = Tenant::query()
->where('external_id', '!=', 'platform')
->distinct('workspace_id')
->count('workspace_id');
return [
'control_key' => $controlKey,
'scope_type' => 'global',
'workspace_id' => null,
'workspace_count' => $workspaceCount,
'tenant_count' => $tenantCount,
'summary' => sprintf('%s will affect %d %s across %d %s.', $label, $workspaceCount, $workspaceCount === 1 ? 'workspace' : 'workspaces', $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
];
}
public function pauseRestoreExecuteAction(): Action
{
return $this->pauseActionFor('restore.execute');
}
public function resumeRestoreExecuteAction(): Action
{
return $this->resumeActionFor('restore.execute');
}
public function viewHistoryRestoreExecuteAction(): Action
{
return $this->historyActionFor('restore.execute');
}
public function pauseAiExecutionAction(): Action
{
return $this->pauseActionFor('ai.execution');
}
public function resumeAiExecutionAction(): Action
{
return $this->resumeActionFor('ai.execution');
}
public function viewHistoryAiExecutionAction(): Action
{
return $this->historyActionFor('ai.execution');
}
private function pauseActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
return Action::make('pause_'.$this->actionSlug($controlKey))
->label('Pause '.$label)
->icon('heroicon-o-pause')
->color('danger')
->requiresConfirmation()
->modalHeading('Pause '.$label)
->modalDescription('Review the scope impact, reason, and optional expiry before confirming this control change.')
->form($this->pauseFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
(clone $scopeQuery)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->delete();
$activation = (clone $scopeQuery)->notExpired()->first();
$auditAction = $activation instanceof OperationalControlActivation
? AuditActionId::OperationalControlUpdated
: AuditActionId::OperationalControlPaused;
if ($activation instanceof OperationalControlActivation) {
$activation->fill([
'reason_text' => $reasonText,
'expires_at' => $expiresAt,
'updated_by_platform_user_id' => (int) $actor->getKey(),
])->save();
} else {
$activation = OperationalControlActivation::query()->create([
'control_key' => $controlKey,
'scope_type' => $scopeType,
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
'reason_text' => $reasonText,
'expires_at' => $expiresAt,
'created_by_platform_user_id' => (int) $actor->getKey(),
]);
}
$this->recordControlMutation(
auditAction: $auditAction,
activation: $activation,
actor: $actor,
auditRecorder: $auditRecorder,
workspaceAuditLogger: $workspaceAuditLogger,
);
Notification::make()
->title(sprintf('%s %s', $label, $auditAction === AuditActionId::OperationalControlPaused ? 'paused' : 'updated'))
->success()
->send();
});
}
private function resumeActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
return Action::make('resume_'.$this->actionSlug($controlKey))
->label('Resume '.$label)
->icon('heroicon-o-play')
->color('gray')
->requiresConfirmation()
->modalHeading('Resume '.$label)
->modalDescription('Remove the selected pause so new starts can proceed again.')
->form($this->resumeFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
->notExpired()
->first();
if (! $activation instanceof OperationalControlActivation) {
Notification::make()
->title(sprintf('%s already enabled', $label))
->warning()
->send();
return;
}
$activationSnapshot = $activation->replicate();
$activationSnapshot->forceFill($activation->getAttributes());
$activation->delete();
$this->recordControlMutation(
auditAction: AuditActionId::OperationalControlResumed,
activation: $activationSnapshot,
actor: $actor,
auditRecorder: $auditRecorder,
workspaceAuditLogger: $workspaceAuditLogger,
);
Notification::make()
->title($label.' resumed')
->success()
->send();
});
}
private function historyActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
return Action::make('view_history_'.$this->actionSlug($controlKey))
->label('View '.$label.' history')
->link()
->modalHeading($label.' history')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(fn () => view('filament.system.pages.ops.partials.operational-control-history', [
'events' => $this->recentAuditEventsForControl($controlKey),
'label' => $label,
]));
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function pauseFormSchema(string $controlKey): array
{
return [
Radio::make('scope_type')
->label('Scope')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->live()
->required(),
Select::make('workspace_id')
->label('Workspace')
->searchable()
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
->live()
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Textarea::make('reason_text')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
DateTimePicker::make('expires_at')
->label('Expires at')
->seconds(false)
->nullable(),
Placeholder::make('scope_preview')
->label('Scope impact preview')
->content(function (callable $get) use ($controlKey): string {
$preview = $this->scopeImpactPreview(
$controlKey,
(string) ($get('scope_type') ?? 'global'),
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
);
return (string) $preview['summary'];
}),
];
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function resumeFormSchema(string $controlKey): array
{
return [
Radio::make('scope_type')
->label('Scope')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->live()
->required(),
Select::make('workspace_id')
->label('Workspace')
->searchable()
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Placeholder::make('scope_preview')
->label('Resume impact preview')
->content(function (callable $get) use ($controlKey): string {
$preview = $this->scopeImpactPreview(
$controlKey,
(string) ($get('scope_type') ?? 'global'),
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
);
return (string) $preview['summary'];
}),
];
}
private function controlsActor(): PlatformUser
{
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE)) {
abort(403);
}
return $actor;
}
/**
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
*/
private function normalizePauseInput(string $controlKey, array $data): array
{
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
$reasonText = trim((string) ($data['reason_text'] ?? ''));
if ($reasonText === '') {
throw ValidationException::withMessages([
'reason_text' => 'A reason is required.',
]);
}
$expiresAt = null;
if (filled($data['expires_at'] ?? null)) {
$expiresAt = Carbon::parse((string) $data['expires_at']);
if ($expiresAt->lessThanOrEqualTo(now())) {
throw ValidationException::withMessages([
'expires_at' => 'Expiry must be in the future.',
]);
}
}
return [$scopeType, $workspace, $reasonText, $expiresAt];
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function normalizeResumeInput(string $controlKey, array $data): array
{
return $this->resolveScopeInput($controlKey, $data);
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function resolveScopeInput(string $controlKey, array $data): array
{
$scopeType = (string) ($data['scope_type'] ?? 'global');
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
if (! in_array($scopeType, $supportedScopes, true)) {
throw ValidationException::withMessages([
'scope_type' => 'Invalid scope selected.',
]);
}
if ($scopeType === 'global') {
return [$scopeType, null];
}
$workspaceId = $data['workspace_id'] ?? null;
if (! is_numeric($workspaceId)) {
throw ValidationException::withMessages([
'workspace_id' => 'A workspace is required for workspace scope.',
]);
}
$workspace = Workspace::query()->whereKey((int) $workspaceId)->first();
if (! $workspace instanceof Workspace) {
throw ValidationException::withMessages([
'workspace_id' => 'The selected workspace could not be found.',
]);
}
return [$scopeType, $workspace];
}
/**
* @return array<string, string>
*/
private function scopeOptions(string $controlKey): array
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return Arr::only([
'global' => 'Global',
'workspace' => 'One workspace',
], $supportedScopes);
}
private function defaultScopeFor(string $controlKey): string
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return $supportedScopes[0] ?? 'global';
}
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
{
$query = OperationalControlActivation::query()
->forControl($controlKey)
->where('scope_type', $scopeType);
if ($scopeType === 'workspace') {
$query->where('workspace_id', (int) $workspace?->getKey());
} else {
$query->whereNull('workspace_id');
}
return $query;
}
private function recordControlMutation(
AuditActionId $auditAction,
OperationalControlActivation $activation,
PlatformUser $actor,
AuditRecorder $auditRecorder,
WorkspaceAuditLogger $workspaceAuditLogger,
): void {
$label = app(OperationalControlCatalog::class)->label((string) $activation->control_key);
$summary = sprintf('%s %s', $label, match ($auditAction) {
AuditActionId::OperationalControlPaused => 'paused',
AuditActionId::OperationalControlUpdated => 'updated',
AuditActionId::OperationalControlResumed => 'resumed',
default => 'changed',
});
$metadata = array_filter([
'control_key' => (string) $activation->control_key,
'scope_type' => (string) $activation->scope_type,
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
'reason_text' => $activation->reason_text,
'expires_at' => $activation->expires_at?->toIso8601String(),
'actor_id' => (int) $actor->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== '');
if ((string) $activation->scope_type === 'global') {
$auditRecorder->record(
action: $auditAction,
context: ['metadata' => $metadata],
actor: AuditActorSnapshot::platform($actor),
target: new AuditTargetSnapshot(
type: 'operational_control',
id: (string) $activation->getKey(),
label: $label,
),
outcome: 'success',
summary: $summary,
);
return;
}
$workspace = Workspace::query()->whereKey((int) $activation->workspace_id)->firstOrFail();
$workspaceAuditLogger->log(
workspace: $workspace,
action: $auditAction,
context: ['metadata' => $metadata],
actor: $actor,
status: 'success',
resourceType: 'operational_control',
resourceId: (string) $activation->getKey(),
targetLabel: $label,
summary: $summary,
);
}
/**
* @return Collection<int, OperationalControlActivation>
*/
private function activeActivationsForControl(string $controlKey): Collection
{
return OperationalControlActivation::query()
->forControl($controlKey)
->notExpired()
->with(['workspace', 'createdBy', 'updatedBy'])
->orderByRaw("CASE WHEN scope_type = 'global' THEN 0 ELSE 1 END")
->orderBy('workspace_id')
->orderBy('id')
->get();
}
/**
* @return array<string, mixed>
*/
private function activationSummary(OperationalControlActivation $activation): array
{
$owner = $activation->updatedBy ?? $activation->createdBy;
$workspaceName = $activation->workspace?->name;
return [
'id' => (int) $activation->getKey(),
'scope_type' => (string) $activation->scope_type,
'scope_label' => (string) $activation->scope_type === 'global'
? 'Global'
: sprintf('Workspace: %s', $workspaceName ?? '#'.(int) $activation->workspace_id),
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
'workspace_name' => $workspaceName,
'reason_text' => (string) $activation->reason_text,
'expires_at' => $activation->expires_at?->toIso8601String(),
'expires_label' => $activation->expires_at?->diffForHumans() ?? 'No expiry',
'owner_name' => $owner?->name ?: $owner?->email ?: 'Unknown operator',
];
}
/**
* @return Collection<int, AuditLog>
*/
private function recentAuditEventsForControl(string $controlKey): Collection
{
return AuditLog::query()
->where('metadata->control_key', $controlKey)
->whereIn('action', [
AuditActionId::OperationalControlPaused->value,
AuditActionId::OperationalControlUpdated->value,
AuditActionId::OperationalControlResumed->value,
AuditActionId::OperationalControlExecutionBlocked->value,
])
->latestFirst()
->limit(10)
->get();
}
private function actionSlug(string $controlKey): string
{
return str_replace('.', '_', $controlKey);
}
}

View File

@ -4,9 +4,25 @@
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\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
{
@ -20,6 +36,53 @@ 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();
@ -31,4 +94,221 @@ public static function canAccess(): bool
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
}
/**
* @return array<Action>
*/
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,
]);
$run = $runbookService->start(
scope: $scope,
initiator: $user,
reason: $reason,
source: 'system_ui',
);
$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<int, \Filament\Schemas\Components\Component>
*/
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<int, \Filament\Schemas\Components\Component>
*/
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<string, mixed> $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());
}
}

View File

@ -19,14 +19,12 @@ class ControlTowerKpis extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
$start = $window->startAt();
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);

View File

@ -21,14 +21,12 @@ class ControlTowerRecentFailures extends Widget
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
public ?string $window = null;
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
$start = $window->startAt();
/** @var Collection<int, OperationRun> $runs */

View File

@ -23,14 +23,12 @@ class ControlTowerTopOffenders extends Widget
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
public ?string $window = null;
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
$start = $window->startAt();
/** @var Collection<int, OperationRun> $grouped */

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class CustomerHealthKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected ?string $heading = 'Customer health';
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$counts = app(WorkspaceHealthSummaryQuery::class)->healthCounts($window);
return [
Stat::make('Healthy', $counts['ok'])
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
->color($counts['ok'] > 0 ? 'success' : 'gray'),
Stat::make('Warning', $counts['warn'])
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
Stat::make('Critical', $counts['critical'])
->description('Overall workspace health is derived from existing system truth only.')
->color($counts['critical'] > 0 ? 'danger' : 'gray'),
Stat::make('Unknown', $counts['unknown'])
->description('Missing or stale inputs stay explicit instead of silently reading healthy.')
->color('gray'),
];
}
}

View File

@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use App\Support\System\SystemOperationRunLinks;
use Filament\Widgets\Widget;
class CustomerHealthTopWorkspaces extends Widget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.system.widgets.customer-health-top-workspaces';
public ?string $window = null;
public static function canView(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
return true;
}
if (! static::canOpenRuns($user)) {
return false;
}
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
return app(WorkspaceHealthSummaryQuery::class)
->attentionNeeded($window, 10)
->contains(fn (array $summary): bool => static::canAccessNextLink($summary['next_link'], $user));
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$user = auth('platform')->user();
return [
'windowLabel' => $windowLabel,
'rows' => app(WorkspaceHealthSummaryQuery::class)
->attentionNeeded($window, 10)
->filter(fn (array $summary): bool => $user instanceof PlatformUser && static::canAccessNextLink($summary['next_link'], $user))
->map(fn (array $summary): array => $this->presentSummary($summary)),
];
}
/**
* @param array{label: string, url: string} $nextLink
*/
private static function canAccessNextLink(array $nextLink, PlatformUser $user): bool
{
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
return true;
}
return static::canOpenRuns($user)
&& $nextLink['url'] === SystemOperationRunLinks::index();
}
private static function canOpenRuns(PlatformUser $user): bool
{
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
/**
* @param array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* } $summary
* @return array{
* workspace_id: int,
* workspace_label: string,
* overall: array{label: string, color: string, icon: ?string},
* dominant_copy: string,
* dominant_dimensions: list<array{label: string, color: string, icon: ?string}>,
* next_link: array{label: string, url: string}
* }
*/
private function presentSummary(array $summary): array
{
$dominantDimensions = collect($summary['dominant_dimension_keys'])
->take(2)
->map(function (string $dimensionKey) use ($summary): array {
$dimension = $summary['dimensions'][$dimensionKey];
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
return [
'label' => $dimension['label'],
'color' => $badge->color,
'icon' => $badge->icon,
];
})
->values()
->all();
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
return [
'workspace_id' => $summary['workspace_id'],
'workspace_label' => $summary['workspace_name'],
'overall' => [
'label' => $overallBadge->label,
'color' => $overallBadge->color,
'icon' => $overallBadge->icon,
],
'dominant_copy' => implode(', ', array_column($dominantDimensions, 'label')),
'dominant_dimensions' => $dominantDimensions,
'next_link' => $summary['next_link'],
];
}
}

View File

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ProductTelemetryKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected ?string $heading = 'Product telemetry';
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$summary = app(ProductTelemetrySummaryQuery::class)->summarize($window->startAt(), now());
$stats = [
Stat::make('Active workspaces', $summary['active_workspaces'])
->description($summary['total_events'] > 0
? sprintf('%d events in %s', $summary['total_events'], $windowLabel)
: sprintf('No telemetry recorded in %s.', $windowLabel))
->color($summary['active_workspaces'] > 0 ? 'primary' : 'gray'),
];
foreach ($summary['families'] as $family) {
$stats[] = Stat::make($family['label'], $family['count'])
->description($windowLabel)
->color($family['count'] > 0 ? 'primary' : 'gray');
}
return $stats;
}
}

View File

@ -14,9 +14,7 @@
use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade;
@ -58,7 +56,7 @@ protected function getStats(): array
$inventoryOps = (int) OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
->where('type', 'inventory_sync')
->active()
->count();

View File

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

View File

@ -4,8 +4,6 @@
namespace App\Filament\Widgets\Tenant;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant;
@ -20,7 +18,6 @@
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class TenantReviewPackCard extends Widget
@ -69,26 +66,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
/** @var ReviewPackService $service */
$service = app(ReviewPackService::class);
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
if ((bool) ($decision['is_blocked'] ?? false)) {
Notification::make()
->title('Review pack generation unavailable')
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
->warning()
->send();
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())
@ -113,20 +90,10 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return;
}
try {
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->warning()
->send();
return;
}
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
$runUrl = $reviewPack->operationRun
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
@ -163,17 +130,6 @@ protected function getViewData(): array
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$service = app(ReviewPackService::class);
$generationEntitlement = $canManage
? $service->reviewPackGenerationDecisionForTenant($tenant)
: null;
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
$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'])
@ -190,10 +146,6 @@ protected function getViewData(): array
'pollingInterval' => null,
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null,
'failedReason' => null,
'reviewUrl' => null,
@ -242,10 +194,6 @@ protected function getViewData(): array
'pollingInterval' => self::resolvePollingInterval($latestPack),
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
@ -276,10 +224,6 @@ private function emptyState(): array
'pollingInterval' => null,
'canView' => false,
'canManage' => false,
'generationBlocked' => false,
'generationBlockReason' => null,
'generationWarningReason' => null,
'customerWorkspaceUrl' => null,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,

View File

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

View File

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\Localization\LocaleResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
class LocalizationController extends Controller
{
public function context(Request $request, LocaleResolver $resolver): JsonResponse
{
$plane = $request->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'));
}
}

View File

@ -4,12 +4,7 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -20,21 +15,6 @@ class ReviewPackDownloadController extends Controller
{
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
{
$user = $request->user();
$tenant = $reviewPack->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
if (! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
abort(403);
}
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
throw new NotFoundHttpException;
}
@ -49,26 +29,7 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
throw new NotFoundHttpException;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::ReviewPackDownloaded,
context: [
'metadata' => [
'review_pack_id' => (int) $reviewPack->getKey(),
'tenant_review_id' => $reviewPack->tenant_review_id !== null
? (int) $reviewPack->tenant_review_id
: null,
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
],
],
actor: $user,
resourceType: 'review_pack',
resourceId: (string) $reviewPack->getKey(),
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
tenant: $tenant,
operationRunId: $reviewPack->operation_run_id,
);
$tenant = $reviewPack->tenant;
$filename = sprintf(
'review-pack-%s-%s.zip',
$tenant?->external_id ?? 'unknown',

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Localization\LocaleResolver;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class ApplyResolvedLocale
{
public function __construct(private LocaleResolver $resolver) {}
public function handle(Request $request, Closure $next, ?string $plane = null): Response
{
$context = $this->resolver->resolve($request, $plane);
App::setLocale($context['locale']);
Carbon::setLocale($context['locale']);
$request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context);
return $next($request);
}
}

View File

@ -8,10 +8,8 @@
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
@ -38,10 +36,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
'tenant_id' => (int) $schedule->tenant_id,
'user_id' => null,
'initiator_name' => 'System',
'type' => OperationRunType::BackupScheduleRetention->value,
'type' => 'backup_schedule_retention',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':'.OperationRunType::BackupScheduleRetention->value.':'.$schedule->id.':'.Str::uuid()->toString()),
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
'context' => [
'backup_schedule_id' => (int) $schedule->id,
],
@ -90,7 +88,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
/** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id)
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
->where('type', 'backup_schedule_run')
->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id')
@ -105,7 +103,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
/** @var Collection<int, int> $allBackupSetIds */
$allBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id)
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
->where('type', 'backup_schedule_run')
->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id')

View File

@ -0,0 +1,395 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Throwable;
class BackfillFindingLifecycleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $tenantId,
public readonly int $workspaceId,
public readonly ?int $initiatorUserId = null,
) {}
public function handle(
OperationRunService $operationRuns,
FindingSlaPolicy $slaPolicy,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$tenant = Tenant::query()->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_RESOLVED,
'resolved_at' => $backfillStartedAt,
'resolved_reason' => 'consolidated_duplicate',
'recurrence_key' => null,
])->save();
$consolidated++;
}
}
return $consolidated;
}
/**
* @param Collection<int, Finding> $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;
}
}

View File

@ -0,0 +1,375 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Support\OpsUx\RunFailureSanitizer;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Throwable;
class BackfillFindingLifecycleTenantIntoWorkspaceRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $operationRunId,
public readonly int $workspaceId,
public readonly int $tenantId,
) {}
public function handle(
OperationRunService $operationRunService,
FindingSlaPolicy $slaPolicy,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$tenant = Tenant::query()->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_RESOLVED,
'resolved_at' => $backfillStartedAt,
'resolved_reason' => 'consolidated_duplicate',
'recurrence_key' => null,
])->save();
$consolidated++;
}
}
return $consolidated;
}
/**
* @param Collection<int, Finding> $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;
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\System\AllowedTenantUniverse;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class BackfillFindingLifecycleWorkspaceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $operationRunId,
public readonly int $workspaceId,
) {}
public function handle(
OperationRunService $operationRunService,
AllowedTenantUniverse $allowedTenantUniverse,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$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;
}
$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,
);
}
}
}

View File

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

View File

@ -1871,11 +1871,8 @@ private function upsertFindings(
} else {
$this->observeFinding(
finding: $finding,
tenant: $tenant,
observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(),
severity: (string) $driftItem['severity'],
slaPolicy: $slaPolicy,
);
}
@ -1950,21 +1947,12 @@ private function upsertFindings(
];
}
private function observeFinding(
Finding $finding,
Tenant $tenant,
CarbonImmutable $observedAt,
int $currentOperationRunId,
string $severity,
FindingSlaPolicy $slaPolicy,
): void
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): 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;
}
@ -1976,14 +1964,6 @@ private function observeFinding(
} 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);
}
}
/**

View File

@ -452,11 +452,6 @@ private function logVerificationResult(
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
),
'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(),
'previous_consent_status' => $previousConsentStatus,

View File

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

View File

@ -2,8 +2,6 @@
namespace App\Models;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -36,11 +34,11 @@ public function tenant(): BelongsTo
public function operationRuns(): HasMany
{
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
->whereIn('type', array_values(array_unique(array_merge(
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value),
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleRetention->value),
OperationCatalog::rawValuesForCanonical(OperationRunType::BackupSchedulePurge->value),
))))
->whereIn('type', [
'backup_schedule_run',
'backup_schedule_retention',
'backup_schedule_purge',
])
->where('context->backup_schedule_id', (int) $this->getKey());
}
}

View File

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

View File

@ -45,17 +45,4 @@ public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return list<array<string, mixed>>
*/
public function canonicalControlReferences(): array
{
$payload = is_array($this->summary_payload) ? $this->summary_payload : [];
$references = $payload['canonical_controls'] ?? [];
return is_array($references)
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: [];
}
}

View File

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

View File

@ -98,7 +98,7 @@ public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|i
: (int) $profile;
return $query
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value))
->where('type', OperationRunType::BaselineCompare->value)
->where('context->baseline_profile_id', $profileId);
}
@ -112,7 +112,7 @@ public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $poli
foreach ($policy->coveredTypeNames() as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
@ -152,18 +152,12 @@ public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $po
return $query
->active()
->where(function (Builder $query) use ($coveredTypes, $policy): void {
$coveredRawTypes = collect($coveredTypes)
->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type))
->unique()
->values()
->all();
$query->whereNotIn('type', $coveredRawTypes);
$query->whereNotIn('type', $coveredTypes);
foreach ($coveredTypes as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->whereIn('type', OperationCatalog::rawValuesForCanonical($type))
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
@ -349,7 +343,7 @@ public static function latestCompletedCoverageBearingInventorySyncForTenant(int
return static::query()
->where('tenant_id', $tenantId)
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
@ -484,11 +478,11 @@ public function baselineGapEnvelope(): array
{
$context = is_array($this->context) ? $this->context : [];
return match ($this->canonicalOperationType()) {
'baseline.compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
return match ((string) $this->type) {
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
? data_get($context, 'baseline_compare.evidence_gaps')
: [],
'baseline.capture' => is_array(data_get($context, 'baseline_capture.gaps'))
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
? data_get($context, 'baseline_capture.gaps')
: [],
default => [],

View File

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\OperationalControlActivationFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OperationalControlActivation extends Model
{
/** @use HasFactory<OperationalControlActivationFactory> */
use HasFactory;
protected $guarded = [];
protected $casts = [
'expires_at' => 'datetime',
];
protected static function newFactory(): OperationalControlActivationFactory
{
return OperationalControlActivationFactory::new();
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(PlatformUser::class, 'created_by_platform_user_id');
}
public function updatedBy(): BelongsTo
{
return $this->belongsTo(PlatformUser::class, 'updated_by_platform_user_id');
}
public function scopeForControl(Builder $query, string $controlKey): Builder
{
return $query->where('control_key', trim($controlKey));
}
public function scopeForGlobalScope(Builder $query): Builder
{
return $query->where('scope_type', 'global');
}
public function scopeForWorkspaceScope(Builder $query, int|Workspace $workspace): Builder
{
$workspaceId = $workspace instanceof Workspace
? (int) $workspace->getKey()
: (int) $workspace;
return $query
->where('scope_type', 'workspace')
->where('workspace_id', $workspaceId);
}
public function scopeNotExpired(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
}

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