Compare commits

...

24 Commits

Author SHA1 Message Date
Ahmed Darrazi
52e72ac34a feat(specs/043): cross tenant compare and promotion
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m30s
2026-04-30 09:42:46 +02:00
Ahmed Darrazi
a35cd88bff Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
2026-04-30 00:43:39 +02:00
926b0fe4f3 feat(specs/257): governance decision convergence (#304)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
Automatisch erstellter PR: Implementiert Spec 257 — Governance decision convergence.

Branch: 257-governance-decision-convergence

Bitte Review und Merge gegen `platform-dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #304
2026-04-29 22:36:05 +00:00
Ahmed Darrazi
a74a6791ad Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m49s
2026-04-29 22:50:20 +02:00
52ebf63af1 feat(specs/256): external support desk handoff (#301)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 2m6s
Implement external support desk handoff (spec 256). Created and pushed branch `256-external-support-desk-handoff`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #301
2026-04-29 20:16:40 +00:00
Ahmed Darrazi
2e2b125107 Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m15s
2026-04-29 14:58:56 +02:00
Ahmed Darrazi
4b0dc2a62e chore: commit workspace changes (automated)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 53s
2026-04-29 14:56:17 +02:00
Ahmed Darrazi
34351a281d Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
2026-04-29 14:37:00 +02:00
51ea80ca05 Automatische PR: 255-enforce-finding-creation-invariants → platform-dev (#298)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m5s
Automatisch erstellt: Commit & Push aus Workspace (WIP)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #298
2026-04-29 12:26:21 +00:00
Ahmed Darrazi
e36bd3ca9c merge: sync dev into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
2026-04-29 09:47:47 +02:00
b511b08371 feat: remove findings acknowledged compatibility and unify canonical operation types (#296)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m0s
This PR removes the legacy "acknowledged" status compatibility for findings and unifies the canonical operation types (e.g., transitioning from baseline_capture to baseline.capture). It includes updated tests, models, and services to reflect these changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #296
2026-04-29 07:34:39 +00:00
Ahmed Darrazi
f53f149f99 Merge remote-tracking branch 'origin/platform-dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
# Conflicts:
#	.github/agents/copilot-instructions.md
2026-04-29 00:08:57 +02:00
2fa8fc0f87 refactor: remove findings lifecycle backfill runtime surfaces (#294)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 51s
## Summary
- decommission the legacy findings lifecycle backfill substrate across command, job, service, and UI layers
- remove related platform capabilities, operation catalog entries, and action surface exemptions
- add regression and removal verification tests to ensure runtime integrity and surface absence
- include spec, plan, tasks, and data-model artifacts for the removal slice

## Scope
- active spec: specs/253-remove-findings-backfill-runtime-surfaces
- target branch: dev

## Validation
- integrated regression and removal verification tests for console, findings, and system ops surfaces
- audit log and capability trace verification for the removal path

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #294
2026-04-28 22:00:51 +00:00
Ahmed Darrazi
44e6a1eb05 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 21:46:29 +02:00
Ahmed Darrazi
4f7c1a6c94 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 15:41:58 +02:00
Ahmed Darrazi
4325e1ed8d Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 12:18:08 +02:00
Ahmed Darrazi
4ae4c2ee95 chore: add gitea MCP helper script
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 58s
2026-04-28 09:26:51 +02:00
Ahmed Darrazi
32b6dcb937 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 09:22:09 +02:00
Ahmed Darrazi
f7bc4f2787 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 23:22:08 +02:00
Ahmed Darrazi
0739018ee5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 19:36:43 +02:00
Ahmed Darrazi
9a02261f5c Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 15:03:58 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
41 changed files with 5242 additions and 123 deletions

View File

@ -0,0 +1,625 @@
---
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

@ -0,0 +1,674 @@
<?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,14 +105,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_tenant_filter')
->label('Clear tenant filter') $governanceContext = $this->incomingGovernanceContext();
->icon('heroicon-o-x-mark')
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') ->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->url($governanceContext->backLinkUrl);
->action(fn (): mixed => $this->clearTenantFilter()), }
];
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter());
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext
); );
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(array $overrides = []): string private function queueUrl(array $overrides = []): string
{ {
$resolvedTenant = array_key_exists('tenant', $overrides) $resolvedTenant = array_key_exists('tenant', $overrides)

View File

@ -97,14 +97,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_tenant_filter')
->label('Clear tenant filter') $governanceContext = $this->incomingGovernanceContext();
->icon('heroicon-o-x-mark')
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') ->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->url($governanceContext->backLinkUrl);
->action(fn (): mixed => $this->clearTenantFilter()), }
];
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter());
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext
); );
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(): string private function queueUrl(): string
{ {
$tenant = $this->filteredTenant(); $tenant = $this->filteredTenant();

View File

@ -75,6 +75,8 @@ class GovernanceInbox extends Page
private ?bool $visibleAlertsFamily = null; private ?bool $visibleAlertsFamily = null;
private ?bool $visibleFindingExceptionsFamily = null;
public ?int $tenantId = null; public ?int $tenantId = null;
public ?string $family = null; public ?string $family = null;
@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string
public function navigationContext(): CanonicalNavigationContext public function navigationContext(): CanonicalNavigationContext
{ {
return new CanonicalNavigationContext( return CanonicalNavigationContext::forGovernanceInbox(
sourceSurface: 'governance.inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId, tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(), backLinkUrl: $this->pageUrl(),
familyKey: $this->family,
); );
} }
@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void
if ( if (
$this->hasVisibleOperationsFamily() $this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== [] || $this->visibleFindingTenants() !== []
|| $this->hasVisibleFindingExceptionsFamily()
|| $this->reviewTenants() !== [] || $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily() || $this->hasVisibleAlertsFamily()
) { ) {
@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); 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> * @return array<int, Tenant>
*/ */
@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string
return in_array($family, [ return in_array($family, [
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -424,6 +448,7 @@ private function inboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(), visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(), reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(), canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(), selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family, selectedFamily: $this->family,
navigationContext: $this->navigationContext(), navigationContext: $this->navigationContext(),
@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(), visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(), reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(), canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: null, selectedTenant: null,
selectedFamily: null, selectedFamily: null,
navigationContext: $this->navigationContext(), navigationContext: $this->navigationContext(),
@ -491,4 +517,4 @@ private function tenantFilterAloneExcludesRows(): bool
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0; return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
} }
} }

View File

@ -208,6 +208,16 @@ protected function getHeaderActions(): array
returnActionName: 'operate_hub_return_finding_exceptions', 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') $actions[] = Action::make('clear_filters')
->label('Clear filters') ->label('Clear filters')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string
return null; return null;
} }
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant); return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
} }
public function selectedFindingUrl(): ?string public function selectedFindingUrl(): ?string
@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string
return null; return null;
} }
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant); return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
} }
public function clearSelectedException(): void public function clearSelectedException(): void
@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext
return CanonicalNavigationContext::fromRequest(request()); return CanonicalNavigationContext::fromRequest(request());
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function normalizeSelectedFindingExceptionId(): void private function normalizeSelectedFindingExceptionId(): void
{ {
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) { if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string
return 'danger'; 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

@ -15,6 +15,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -112,16 +113,28 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_filters')
->label(__('localization.review.clear_filters')) $governanceContext = $this->incomingGovernanceContext();
->icon('heroicon-o-x-mark')
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') ->color('gray')
->visible(fn (): bool => $this->hasActiveFilters()) ->url($governanceContext->backLinkUrl);
->action(function (): void { }
$this->clearWorkspaceFilters();
}), $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 public function table(Table $table): Table
@ -348,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string
return null; return null;
} }
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([ return $this->appendQuery(
self::DETAIL_CONTEXT_QUERY_KEY => 1, TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
]); array_replace(
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
$this->navigationContext()?->toQuery() ?? [],
),
);
} }
private function latestReviewPack(Tenant $tenant): ?ReviewPack private function latestReviewPack(Tenant $tenant): ?ReviewPack
@ -527,4 +544,30 @@ private function reviewPackAvailability(Tenant $tenant): string
return __('localization.review.available'); return __('localization.review.available');
} }
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
/**
* @param array<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

@ -2,6 +2,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers; use App\Filament\Resources\TenantResource\RelationManagers;
@ -15,9 +16,11 @@
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
@ -44,6 +47,7 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -68,6 +72,7 @@
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -824,6 +829,27 @@ public static function table(Table $table): Table
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), && 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( UiEnforcement::forAction(
Actions\Action::make('edit') Actions\Action::make('edit')
->label('Edit') ->label('Edit')
@ -966,6 +992,34 @@ public static function table(Table $table): Table
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ 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') Actions\BulkAction::make('syncSelected')
->label('Sync selected') ->label('Sync selected')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
@ -1158,6 +1212,52 @@ 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{ * @param array{
* backup_posture?: list<string>, * backup_posture?: list<string>,
@ -1248,6 +1348,168 @@ 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( private static function hasActivePortfolioTriageState(
array $backupPostures, array $backupPostures,
array $recoveryEvidence, array $recoveryEvidence,

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Governance\GovernanceInbox;
@ -181,6 +182,7 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class, InventoryCoverage::class,
TenantRequiredPermissions::class, TenantRequiredPermissions::class,
WorkspaceSettings::class, WorkspaceSettings::class,
CrossTenantComparePage::class,
GovernanceInbox::class, GovernanceInbox::class,
FindingsHygieneReport::class, FindingsHygieneReport::class,
FindingsIntakeQueue::class, FindingsIntakeQueue::class,

View File

@ -139,6 +139,43 @@ public function logSupportDiagnosticsOpened(
); );
} }
/**
* @param array<string, mixed> $preflight
*/
public function logCrossTenantPromotionPreflightGenerated(
Workspace $workspace,
Tenant $sourceTenant,
Tenant $targetTenant,
array $preflight,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
$summary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
return $this->log(
workspace: $workspace,
action: AuditActionId::CrossTenantPromotionPreflightGenerated,
context: [
'source_tenant_id' => (int) $sourceTenant->getKey(),
'source_tenant_name' => (string) $sourceTenant->name,
'target_tenant_id' => (int) $targetTenant->getKey(),
'target_tenant_name' => (string) $targetTenant->name,
'ready_count' => (int) ($summary['ready'] ?? 0),
'blocked_count' => (int) ($summary['blocked'] ?? 0),
'manual_mapping_required_count' => (int) ($summary['manual_mapping_required'] ?? 0),
'total_count' => (int) ($summary['total'] ?? 0),
'blocked_reason_counts' => is_array($preflight['blockedReasonCounts'] ?? null)
? $preflight['blockedReasonCounts']
: [],
],
actor: $actor,
status: 'success',
resourceType: 'cross_tenant_promotion_preflight',
resourceId: sprintf('%s:%s', $sourceTenant->getKey(), $targetTenant->getKey()),
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
summary: 'Cross-tenant promotion preflight generated for '.$sourceTenant->name.' -> '.$targetTenant->name,
);
}
public function logSupportRequestCreated( public function logSupportRequestCreated(
SupportRequest $supportRequest, SupportRequest $supportRequest,
User|PlatformUser|null $actor = null, User|PlatformUser|null $actor = null,

View File

@ -69,6 +69,7 @@ enum AuditActionId: string
case BaselineCompareStarted = 'baseline_compare.started'; case BaselineCompareStarted = 'baseline_compare.started';
case BaselineCompareCompleted = 'baseline_compare.completed'; case BaselineCompareCompleted = 'baseline_compare.completed';
case BaselineCompareFailed = 'baseline_compare.failed'; case BaselineCompareFailed = 'baseline_compare.failed';
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentCreated = 'baseline_assignment.created';
case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentUpdated = 'baseline_assignment.updated';
case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
@ -218,6 +219,7 @@ private static function labels(): array
self::BaselineCompareStarted->value => 'Baseline compare started', self::BaselineCompareStarted->value => 'Baseline compare started',
self::BaselineCompareCompleted->value => 'Baseline compare completed', self::BaselineCompareCompleted->value => 'Baseline compare completed',
self::BaselineCompareFailed->value => 'Baseline compare failed', self::BaselineCompareFailed->value => 'Baseline compare failed',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::BaselineAssignmentCreated->value => 'Baseline assignment created', self::BaselineAssignmentCreated->value => 'Baseline assignment created',
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated', self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted', self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
@ -312,6 +314,7 @@ private static function summaries(): array
self::BaselineProfileUpdated->value => 'Baseline profile updated', self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived', self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled', self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::AlertDestinationCreated->value => 'Alert destination created', self::AlertDestinationCreated->value => 'Alert destination created',
self::AlertDestinationUpdated->value => 'Alert destination updated', self::AlertDestinationUpdated->value => 'Alert destination updated',
self::AlertDestinationDeleted->value => 'Alert destination deleted', self::AlertDestinationDeleted->value => 'Alert destination deleted',

View File

@ -6,14 +6,15 @@
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -21,14 +22,12 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService; use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver; use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder final readonly class GovernanceInboxSectionBuilder
@ -41,6 +40,7 @@
private const FAMILY_ORDER = [ private const FAMILY_ORDER = [
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -71,6 +71,7 @@ public function build(
array $visibleFindingTenants, array $visibleFindingTenants,
array $reviewTenants, array $reviewTenants,
bool $canViewAlerts, bool $canViewAlerts,
bool $canViewFindingExceptions = false,
?Tenant $selectedTenant = null, ?Tenant $selectedTenant = null,
?string $selectedFamily = null, ?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null, ?CanonicalNavigationContext $navigationContext = null,
@ -113,6 +114,22 @@ public function build(
} }
if ($authorizedTenantsById !== []) { if ($authorizedTenantsById !== []) {
if ($canViewFindingExceptions) {
$findingExceptionsSection = $this->findingExceptionsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
$availableFamilies[] = [
'key' => $findingExceptionsSection['key'],
'label' => $findingExceptionsSection['label'],
'count' => $findingExceptionsSection['count'],
];
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
}
$operationsSection = $this->operationsSection( $operationsSection = $this->operationsSection(
workspace: $workspace, workspace: $workspace,
authorizedTenants: $authorizedTenantsById, authorizedTenants: $authorizedTenantsById,
@ -191,6 +208,59 @@ public function build(
]; ];
} }
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function findingExceptionsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$pendingCount = (clone $baseQuery)
->where('status', FindingException::STATUS_PENDING)
->count();
$expiringCount = (clone $baseQuery)
->where('current_validity_state', FindingException::VALIDITY_EXPIRING)
->count();
$lapsedCount = (clone $baseQuery)
->where('status', '!=', FindingException::STATUS_PENDING)
->whereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
])
->count();
$entries = $this->orderedFindingExceptionsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext))
->all();
return [
'key' => 'finding_exceptions',
'label' => 'Finding exceptions',
'count' => $count,
'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount),
'dominant_action_label' => 'Open finding exceptions',
'dominant_action_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No finding exceptions match this tenant filter right now.'
: 'No finding exceptions need review right now.',
];
}
/** /**
* @param array<int, Tenant> $tenants * @param array<int, Tenant> $tenants
* @return array<int, Tenant> * @return array<int, Tenant>
@ -477,28 +547,10 @@ private function reviewFollowUpSection(
'label' => 'Review follow-up', 'label' => 'Review follow-up',
'count' => count($rawEntries), 'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount), 'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up', 'dominant_action_label' => 'Open customer review workspace',
'dominant_action_url' => $selectedTenant instanceof Tenant 'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( : $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant 'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.' ? 'No review follow-up is visible for this tenant filter right now.'
@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te
}); });
} }
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($authorizedTenants);
return FindingException::query()
->with([
'tenant',
'requester:id,name',
'owner:id,name',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
])
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where(function ($query): void {
$query
->where('status', FindingException::STATUS_PENDING)
->orWhereIn('status', [
FindingException::STATUS_EXPIRING,
FindingException::STATUS_EXPIRED,
])
->orWhereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
]);
});
}
private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when status = ? then 0
when current_validity_state = ? then 1
when current_validity_state = ? then 2
when current_validity_state = ? then 3
else 4
end asc",
[
FindingException::STATUS_PENDING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
FindingException::VALIDITY_EXPIRING,
],
)
->orderByRaw('case when review_due_at is null then 1 else 0 end asc')
->orderBy('review_due_at')
->orderByDesc('id');
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
]; ];
} }
/**
* @return array<string, mixed>
*/
private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array
{
$findingLabel = $exception->finding?->resolvedSubjectDisplayName()
?? 'Finding #'.$exception->finding_id;
$sublineParts = array_values(array_filter([
$exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null,
FindingExceptionResource::relativeTimeDescription($exception->review_due_at)
?? FindingExceptionResource::relativeTimeDescription($exception->expires_at),
is_string($exception->request_reason) && $exception->request_reason !== ''
? $exception->request_reason
: null,
]));
return [
'family_key' => 'finding_exceptions',
'source_model' => FindingException::class,
'source_key' => (string) $exception->getKey(),
'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null,
'tenant_label' => $exception->tenant?->name,
'headline' => $findingLabel,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => match (true) {
(string) $exception->status === FindingException::STATUS_PENDING => 0,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1,
(string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3,
default => 4,
},
'status_label' => $this->findingExceptionStatusLabel($exception),
'destination_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $exception->tenant?->external_id,
'exception' => (int) $exception->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array<string, mixed> * @return array<string, mixed>
@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string
); );
} }
private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string
{
if ($count === 0) {
return 'No finding exceptions need review in the current scope.';
}
return sprintf(
'%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.',
$count,
$count === 1 ? '' : 's',
$pendingCount,
$expiringCount,
$lapsedCount,
);
}
private function findingExceptionStatusLabel(FindingException $exception): string
{
if ((string) $exception->status === FindingException::STATUS_PENDING) {
return 'Pending';
}
if (in_array((string) $exception->current_validity_state, [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
], true)) {
return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value();
}
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
}
private function reviewSummary(int $followUpCount, int $changedCount): string private function reviewSummary(int $followUpCount, int $changedCount): string
{ {
$total = $followUpCount + $changedCount; $total = $followUpCount + $changedCount;
@ -885,4 +1072,4 @@ private function appendQuery(string $url, array $query): string
return $url.$separator.http_build_query($query); return $url.$separator.http_build_query($query);
} }
} }

View File

@ -5,8 +5,10 @@
namespace App\Support\Navigation; namespace App\Support\Navigation;
use App\Filament\Pages\BaselineCompareMatrix; use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final readonly class CanonicalNavigationContext final readonly class CanonicalNavigationContext
@ -18,6 +20,7 @@ public function __construct(
public string $sourceSurface, public string $sourceSurface,
public string $canonicalRouteName, public string $canonicalRouteName,
public ?int $tenantId = null, public ?int $tenantId = null,
public ?string $familyKey = null,
public ?string $backLinkLabel = null, public ?string $backLinkLabel = null,
public ?string $backLinkUrl = null, public ?string $backLinkUrl = null,
public array $filterPayload = [], public array $filterPayload = [],
@ -56,12 +59,42 @@ public static function fromRequest(Request $request): ?self
sourceSurface: $sourceSurface, sourceSurface: $sourceSurface,
canonicalRouteName: $canonicalRouteName, canonicalRouteName: $canonicalRouteName,
tenantId: is_numeric($tenantId) ? (int) $tenantId : null, tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== ''
? (string) $payload['family_key']
: null,
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null, backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null, backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
filterPayload: [], filterPayload: [],
); );
} }
public static function forGovernanceInbox(
string $canonicalRouteName,
?int $tenantId,
?string $familyKey,
string $backLinkUrl,
): self {
return new self(
sourceSurface: 'governance.inbox',
canonicalRouteName: $canonicalRouteName,
tenantId: $tenantId,
familyKey: $familyKey,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $backLinkUrl,
);
}
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
{
return new self(
sourceSurface: 'tenant_registry',
canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')),
tenantId: $tenantId,
backLinkLabel: 'Back to tenant registry',
backLinkUrl: $backLinkUrl,
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -117,6 +150,7 @@ private function navPayload(): array
'source_surface' => $this->sourceSurface, 'source_surface' => $this->sourceSurface,
'canonical_route_name' => $this->canonicalRouteName, 'canonical_route_name' => $this->canonicalRouteName,
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'family_key' => $this->familyKey,
'back_label' => $this->backLinkLabel, 'back_label' => $this->backLinkLabel,
'back_url' => $this->backLinkUrl, 'back_url' => $this->backLinkUrl,
], static fn (mixed $value): bool => $value !== null && $value !== ''); ], static fn (mixed $value): bool => $value !== null && $value !== '');

View File

@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Support\Baselines\BaselineSubjectKey;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class CrossTenantComparePreviewBuilder
{
public function __construct(
private readonly CurrentStateHashResolver $currentStateHashResolver,
) {}
/**
* @return array{
* selection: array{
* workspaceId: int,
* sourceTenantId: int,
* sourceTenantName: string,
* targetTenantId: int,
* targetTenantName: string,
* policyTypes: list<string>
* },
* summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int},
* subjects: list<array<string, mixed>>
* }
*/
public function build(CrossTenantCompareSelection $selection): array
{
$sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes);
$targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes);
$sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']);
$targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $targetIndex['evidence_subjects']);
$subjects = [];
$summary = [
'match' => 0,
'different' => 0,
'missing' => 0,
'ambiguous' => 0,
'blocked' => 0,
'total' => 0,
];
foreach ($sourceIndex['preview_subjects'] as $sourceSubject) {
$previewSubject = $this->buildPreviewSubject(
sourceSubject: $sourceSubject,
sourceTenant: $selection->sourceTenant,
targetTenant: $selection->targetTenant,
targetIndex: $targetIndex['subjects'],
sourceEvidence: $sourceEvidence,
targetEvidence: $targetEvidence,
);
$subjects[] = $previewSubject;
$summary[$previewSubject['state']]++;
$summary['total']++;
}
usort($subjects, function (array $left, array $right): int {
$policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? ''));
if ($policyTypeComparison !== 0) {
return $policyTypeComparison;
}
$displayNameComparison = strcmp(
Str::lower((string) ($left['displayName'] ?? '')),
Str::lower((string) ($right['displayName'] ?? '')),
);
if ($displayNameComparison !== 0) {
return $displayNameComparison;
}
return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? ''));
});
return [
'selection' => [
'workspaceId' => $selection->workspaceId(),
'sourceTenantId' => $selection->sourceTenantId(),
'sourceTenantName' => (string) $selection->sourceTenant->name,
'targetTenantId' => $selection->targetTenantId(),
'targetTenantName' => (string) $selection->targetTenant->name,
'policyTypes' => $selection->policyTypes,
],
'summary' => $summary,
'subjects' => $subjects,
];
}
/**
* @param Tenant $tenant
* @param list<string> $policyTypes
* @return array{
* preview_subjects: list<array<string, mixed>>,
* evidence_subjects: list<array{policy_type: string, subject_external_id: string}>,
* subjects: array<string, array<string, mixed>>
* }
*/
private function indexTenantSubjects(Tenant $tenant, array $policyTypes): array
{
$inventoryItems = InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->when(
$policyTypes !== [],
fn ($query) => $query->whereIn('policy_type', $policyTypes),
)
->orderBy('policy_type')
->orderBy('display_name')
->orderBy('id')
->get();
$subjects = [];
$previewSubjects = [];
$evidenceSubjects = [];
foreach ($inventoryItems as $inventoryItem) {
if (! $inventoryItem instanceof InventoryItem) {
continue;
}
$policyType = trim((string) $inventoryItem->policy_type);
$subjectKey = BaselineSubjectKey::forPolicy(
$policyType,
is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null,
is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null,
);
$subjectRecord = $this->inventorySubjectRecord($tenant, $inventoryItem, $policyType, $subjectKey);
if ($subjectKey === null) {
$previewSubjects[] = [
...$subjectRecord,
'resolution' => 'identifier_missing',
'duplicateCount' => 1,
];
continue;
}
$indexKey = $this->subjectIndexKey($policyType, $subjectKey);
if (! array_key_exists($indexKey, $subjects)) {
$subjects[$indexKey] = [
'policyType' => $policyType,
'subjectKey' => $subjectKey,
'displayName' => $subjectRecord['displayName'],
'items' => [],
];
}
$subjects[$indexKey]['items'][] = $subjectRecord;
}
foreach ($subjects as $indexKey => $subjectGroup) {
$items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : [];
$firstItem = $items[0] ?? null;
if (! is_array($firstItem)) {
continue;
}
$previewSubjects[] = [
...$firstItem,
'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved',
'duplicateCount' => count($items),
];
if (count($items) === 1) {
$evidenceSubjects[] = [
'policy_type' => (string) $firstItem['policyType'],
'subject_external_id' => (string) $firstItem['subjectExternalId'],
];
}
$subjects[$indexKey]['representative'] = $firstItem;
$subjects[$indexKey]['duplicateCount'] = count($items);
}
return [
'preview_subjects' => $previewSubjects,
'evidence_subjects' => $evidenceSubjects,
'subjects' => $subjects,
];
}
/**
* @param array<string, array<string, mixed>> $targetIndex
* @param array<string, ResolvedEvidence|null> $sourceEvidence
* @param array<string, ResolvedEvidence|null> $targetEvidence
* @return array<string, mixed>
*/
private function buildPreviewSubject(
array $sourceSubject,
Tenant $sourceTenant,
Tenant $targetTenant,
array $targetIndex,
array $sourceEvidence,
array $targetEvidence,
): array {
$policyType = (string) ($sourceSubject['policyType'] ?? '');
$displayName = (string) ($sourceSubject['displayName'] ?? '');
$subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null;
$reasonCodes = [];
$state = 'blocked';
$trustLevel = 'unusable';
$sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject);
$targetEvidenceRecord = null;
$targetSubject = null;
if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') {
$reasonCodes[] = 'source_identifier_missing';
} elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') {
$state = 'ambiguous';
$trustLevel = 'diagnostic_only';
$reasonCodes[] = 'source_subject_ambiguous';
} elseif ($subjectKey !== null) {
$targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null;
if (! is_array($targetSubject)) {
$state = 'missing';
$trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence
&& $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
? 'trustworthy'
: 'limited_confidence';
$reasonCodes[] = 'target_subject_missing';
} elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) {
$state = 'ambiguous';
$trustLevel = 'diagnostic_only';
$reasonCodes[] = 'target_subject_ambiguous';
} else {
$representative = $targetSubject['representative'] ?? null;
if (is_array($representative)) {
$targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative);
}
if (! $sourceEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if (! $targetEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'target_evidence_refresh_required';
}
if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) {
$state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different';
$trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
&& $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
? 'trustworthy'
: 'limited_confidence';
if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
$reasonCodes[] = 'target_evidence_refresh_required';
}
} else {
$state = 'blocked';
$trustLevel = 'unusable';
}
}
}
if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if ($state === 'blocked' && $reasonCodes === []) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
$reasonCodes = array_values(array_unique($reasonCodes));
return [
'policyType' => $policyType,
'displayName' => $displayName,
'subjectKey' => $subjectKey,
'state' => $state,
'trustLevel' => $trustLevel,
'reasonCodes' => $reasonCodes,
'source' => $this->subjectSidePayload($sourceTenant, $sourceSubject, $sourceEvidenceRecord),
'target' => $this->subjectSidePayload(
$targetTenant,
is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null,
$targetEvidenceRecord,
),
];
}
/**
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @return array<string, ResolvedEvidence|null>
*/
private function resolvedEvidenceMap(Tenant $tenant, array $subjects): array
{
return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects);
}
/**
* @param array<string, mixed>|null $subject
* @return array<string, mixed>
*/
private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array
{
return [
'tenantId' => (int) $tenant->getKey(),
'tenantName' => (string) $tenant->name,
'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null,
'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null,
'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null,
'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null,
'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null,
'evidence' => $this->evidencePayload($evidence),
];
}
/**
* @return array{
* policyType: string,
* displayName: string,
* subjectKey: ?string,
* inventoryItemId: int,
* subjectExternalId: string,
* lastSeenAt: ?string
* }
*/
private function inventorySubjectRecord(Tenant $tenant, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array
{
$displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : '';
$displayName = $displayName !== ''
? $displayName
: ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType));
return [
'tenantId' => (int) $tenant->getKey(),
'policyType' => $policyType,
'displayName' => $displayName,
'subjectKey' => $subjectKey,
'inventoryItemId' => (int) $inventoryItem->getKey(),
'subjectExternalId' => (string) $inventoryItem->external_id,
'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(),
];
}
/**
* @param array<string, ResolvedEvidence|null> $evidenceMap
*/
private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence
{
$policyType = trim((string) ($subject['policyType'] ?? ''));
$subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? ''));
if ($policyType === '' || $subjectExternalId === '') {
return null;
}
$key = $policyType.'|'.$subjectExternalId;
$evidence = $evidenceMap[$key] ?? null;
return $evidence instanceof ResolvedEvidence ? $evidence : null;
}
/**
* @return array{
* hash: string,
* fidelity: string,
* source: string,
* observedAt: ?string,
* policyVersionId: ?int,
* operationRunId: ?int,
* capturePurpose: ?string
* }|null
*/
private function evidencePayload(?ResolvedEvidence $evidence): ?array
{
if (! $evidence instanceof ResolvedEvidence) {
return null;
}
return [
'hash' => $evidence->hash,
'fidelity' => $evidence->fidelity,
'source' => $evidence->source,
'observedAt' => $evidence->observedAt?->toIso8601String(),
'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null)
? (int) $evidence->meta['policy_version_id']
: null,
'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null)
? (int) $evidence->meta['operation_run_id']
: null,
'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null)
? (string) $evidence->meta['capture_purpose']
: null,
];
}
private function subjectIndexKey(string $policyType, string $subjectKey): string
{
return $policyType.'|'.$subjectKey;
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use App\Models\Tenant;
use InvalidArgumentException;
final readonly class CrossTenantCompareSelection
{
public Tenant $sourceTenant;
public Tenant $targetTenant;
/**
* @var list<string>
*/
public array $policyTypes;
/**
* @param list<string> $policyTypes
*/
public function __construct(
Tenant $sourceTenant,
Tenant $targetTenant,
array $policyTypes = [],
) {
$this->sourceTenant = $sourceTenant;
$this->targetTenant = $targetTenant;
if ((int) $this->sourceTenant->getKey() === (int) $this->targetTenant->getKey()) {
throw new InvalidArgumentException('Source and target tenants must differ.');
}
if ((int) $this->sourceTenant->workspace_id !== (int) $this->targetTenant->workspace_id) {
throw new InvalidArgumentException('Source and target tenants must belong to the same workspace.');
}
$this->policyTypes = $this->normalizePolicyTypes($policyTypes);
}
public function workspaceId(): int
{
return (int) $this->sourceTenant->workspace_id;
}
public function sourceTenantId(): int
{
return (int) $this->sourceTenant->getKey();
}
public function targetTenantId(): int
{
return (int) $this->targetTenant->getKey();
}
public function hasPolicyTypeFilter(): bool
{
return $this->policyTypes !== [];
}
/**
* @param list<string> $policyTypes
* @return list<string>
*/
private function normalizePolicyTypes(array $policyTypes): array
{
$normalized = array_values(array_unique(array_filter(array_map(static function (mixed $policyType): ?string {
if (! is_string($policyType)) {
return null;
}
$normalizedPolicyType = trim($policyType);
return $normalizedPolicyType !== '' ? $normalizedPolicyType : null;
}, $policyTypes))));
sort($normalized, SORT_STRING);
return $normalized;
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
final class CrossTenantPromotionPreflight
{
/**
* @param array{
* selection?: array<string, mixed>,
* subjects?: list<array<string, mixed>>
* } $preview
* @return array{
* selection: array<string, mixed>,
* summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int},
* blockedReasonCounts: array<string, int>,
* buckets: array{
* ready: list<array<string, mixed>>,
* blocked: list<array<string, mixed>>,
* manual_mapping_required: list<array<string, mixed>>
* }
* }
*/
public function build(array $preview): array
{
$subjects = is_array($preview['subjects'] ?? null) ? $preview['subjects'] : [];
$buckets = [
'ready' => [],
'blocked' => [],
'manual_mapping_required' => [],
];
$blockedReasonCounts = [];
foreach ($subjects as $subject) {
if (! is_array($subject)) {
continue;
}
$decision = $this->classifySubject($subject);
$subject['preflight'] = $decision;
$buckets[$decision['bucket']][] = $subject;
if ($decision['bucket'] !== 'ready') {
foreach ($decision['reasonCodes'] as $reasonCode) {
$blockedReasonCounts[$reasonCode] = ($blockedReasonCounts[$reasonCode] ?? 0) + 1;
}
}
}
return [
'selection' => is_array($preview['selection'] ?? null) ? $preview['selection'] : [],
'summary' => [
'ready' => count($buckets['ready']),
'blocked' => count($buckets['blocked']),
'manual_mapping_required' => count($buckets['manual_mapping_required']),
'total' => count($buckets['ready']) + count($buckets['blocked']) + count($buckets['manual_mapping_required']),
],
'blockedReasonCounts' => $blockedReasonCounts,
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $subject
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
*/
private function classifySubject(array $subject): array
{
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
$reasonCodes = is_array($subject['reasonCodes'] ?? null)
? array_values(array_filter($subject['reasonCodes'], 'is_string'))
: [];
if (in_array('source_identifier_missing', $reasonCodes, true)) {
return $this->decision('blocked', ['source_identifier_missing']);
}
if (in_array('source_subject_ambiguous', $reasonCodes, true)) {
return $this->decision('blocked', ['source_subject_ambiguous']);
}
if (in_array('target_subject_ambiguous', $reasonCodes, true) || $state === 'ambiguous') {
return $this->decision('manual_mapping_required', ['target_subject_ambiguous']);
}
$sourceEvidence = is_array(data_get($subject, 'source.evidence')) ? data_get($subject, 'source.evidence') : null;
$targetEvidence = is_array(data_get($subject, 'target.evidence')) ? data_get($subject, 'target.evidence') : null;
if (! $this->evidenceSupportsPromotion($sourceEvidence)) {
return $this->decision('blocked', ['source_evidence_refresh_required']);
}
if ($state !== 'missing' && ! $this->evidenceSupportsPromotion($targetEvidence)) {
return $this->decision('blocked', ['target_evidence_refresh_required']);
}
return match ($state) {
'match' => $this->decision('ready', ['target_already_aligned']),
'different' => $this->decision('ready', ['target_subject_requires_update']),
'missing' => $this->decision('ready', ['target_subject_missing']),
default => $this->decision('blocked', ['source_evidence_refresh_required']),
};
}
/**
* @param array<string, mixed>|null $evidence
*/
private function evidenceSupportsPromotion(?array $evidence): bool
{
return is_array($evidence)
&& is_string($evidence['fidelity'] ?? null)
&& (string) $evidence['fidelity'] === 'content';
}
/**
* @param list<string> $reasonCodes
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
*/
private function decision(string $bucket, array $reasonCodes): array
{
return [
'bucket' => $bucket,
'reasonCodes' => $reasonCodes,
'reasonLabels' => array_map(fn (string $reasonCode): string => $this->reasonLabel($reasonCode), $reasonCodes),
];
}
private function reasonLabel(string $reasonCode): string
{
return match ($reasonCode) {
'source_identifier_missing' => 'Source tenant subject is missing a stable compare identifier.',
'source_subject_ambiguous' => 'Source tenant subject resolves to multiple candidates and cannot drive promotion safely.',
'target_subject_ambiguous' => 'Target tenant has multiple matching subjects and needs manual mapping.',
'source_evidence_refresh_required' => 'Refresh source evidence before relying on this subject.',
'target_evidence_refresh_required' => 'Refresh target evidence before relying on this subject.',
'target_already_aligned' => 'Target tenant already matches the source for this subject.',
'target_subject_requires_update' => 'Target tenant differs from the source but has enough evidence for later promotion planning.',
'target_subject_missing' => 'Target tenant does not currently contain this subject and can be planned as a missing target item.',
default => 'This subject needs additional review before promotion planning can continue.',
};
}
}

View File

@ -0,0 +1,208 @@
<x-filament-panels::page>
@php
$preview = is_array($preview ?? null) ? $preview : null;
$preflight = is_array($preflight ?? null) ? $preflight : null;
$previewSummary = is_array($preview['summary'] ?? null) ? $preview['summary'] : [];
$preflightSummary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
$blockedReasonCounts = is_array($preflight['blockedReasonCounts'] ?? null) ? $preflight['blockedReasonCounts'] : [];
$sourceTenantName = data_get($preview, 'selection.sourceTenantName');
$targetTenantName = data_get($preview, 'selection.targetTenantName');
$selectedPolicyTypes = data_get($preview, 'selection.policyTypes', []);
@endphp
<x-filament::section heading="Cross-tenant compare">
<x-slot name="description">
Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only.
</x-slot>
<div class="space-y-4">
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<form wire:submit.prevent="applySelection" class="space-y-4">
{{ $this->form }}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Shareable compare scope</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Source tenant, target tenant, and governed-subject filters live on the URL so the same compare preview can be reopened or shared.
</p>
</div>
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applySelection,generatePromotionPreflight">
Run compare preview
</x-filament::button>
@if ($this->hasActiveSelection())
<x-filament::button tag="a" :href="$this->clearSelectionUrl()" color="gray" size="sm">
Clear selection
</x-filament::button>
@endif
</div>
</div>
</form>
</div>
@if (filled($selectionMessage))
<div class="rounded-2xl border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-100">
{{ $selectionMessage }}
</div>
@endif
@if ($preview === null)
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-5 py-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
Choose a source tenant and a target tenant to build a compare preview. The source and target must be different tenants inside the active workspace.
</div>
@endif
</div>
</x-filament::section>
@if ($preview !== null)
<x-filament::section heading="Compare preview">
<x-slot name="description">
Decision-first summary of governed subjects. Raw payloads stay on the existing tenant and baseline surfaces.
</x-slot>
<div class="space-y-4" data-testid="cross-tenant-compare-preview">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" size="sm">Source tenant: {{ $sourceTenantName }}</x-filament::badge>
<x-filament::badge color="gray" size="sm">Target tenant: {{ $targetTenantName }}</x-filament::badge>
@foreach ($selectedPolicyTypes as $policyType)
<x-filament::badge color="gray" size="sm">{{ $this->stateLabel($policyType) }}</x-filament::badge>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-3 text-sm">
@if (filled($this->sourceTenantUrl()))
<a href="{{ $this->sourceTenantUrl() }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Open source tenant
</a>
@endif
@if (filled($this->targetTenantUrl()))
<a href="{{ $this->targetTenantUrl() }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Open target tenant
</a>
@endif
</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
The preview groups governed subjects into reproducible compare states so you can decide whether the target is aligned, missing, blocked, or needs manual review.
</p>
</div>
<dl class="grid gap-3 sm:grid-cols-3 xl:w-[28rem]">
@foreach (['match', 'different', 'missing', 'ambiguous', 'blocked', 'total'] as $state)
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $this->stateLabel($state) }}</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">{{ (int) ($previewSummary[$state] ?? 0) }}</dd>
</div>
@endforeach
</dl>
</div>
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-3 border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
<div>Governed subject</div>
<div>Reasoning</div>
<div>Compare state</div>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach (data_get($preview, 'subjects', []) as $subject)
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-3 px-4 py-4" data-testid="cross-tenant-compare-subject">
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ data_get($subject, 'displayName') }}</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{{ $this->stateLabel((string) data_get($subject, 'policyType', 'unknown')) }}</span>
@if (filled(data_get($subject, 'subjectKey')))
<x-filament::badge color="gray" size="sm">{{ data_get($subject, 'subjectKey') }}</x-filament::badge>
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
@forelse (data_get($subject, 'reasonCodes', []) as $reasonCode)
<x-filament::badge color="gray" size="sm">{{ $this->reasonLabel((string) $reasonCode) }}</x-filament::badge>
@empty
<span class="text-sm text-gray-500 dark:text-gray-400">No blocking reason.</span>
@endforelse
</div>
<div class="flex items-start justify-start xl:justify-end">
<x-filament::badge :color="$this->stateColor((string) data_get($subject, 'state', 'unknown'))" size="sm">
{{ $this->stateLabel((string) data_get($subject, 'state', 'unknown')) }}
</x-filament::badge>
</div>
</div>
@endforeach
</div>
</div>
</div>
</x-filament::section>
@endif
@if ($preflight !== null)
<x-filament::section heading="Promotion preflight">
<x-slot name="description">
Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice.
</x-slot>
<div class="space-y-4" data-testid="cross-tenant-preflight">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach (['ready', 'blocked', 'manual_mapping_required', 'total'] as $state)
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $this->stateLabel($state) }}</div>
<div class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">{{ (int) ($preflightSummary[$state] ?? 0) }}</div>
</div>
@endforeach
</div>
@if ($blockedReasonCounts !== [])
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Top blocked reasons</div>
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($blockedReasonCounts as $reasonCode => $count)
<x-filament::badge color="danger" size="sm">{{ $this->reasonLabel((string) $reasonCode) }}: {{ (int) $count }}</x-filament::badge>
@endforeach
</div>
</div>
@endif
<div class="grid gap-4 xl:grid-cols-3">
@foreach (['ready', 'manual_mapping_required', 'blocked'] as $bucket)
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $this->stateLabel($bucket) }}</div>
<x-filament::badge :color="$this->stateColor($bucket)" size="sm">
{{ count(data_get($preflight, 'buckets.'.$bucket, [])) }}
</x-filament::badge>
</div>
<div class="mt-3 space-y-3">
@forelse (data_get($preflight, 'buckets.'.$bucket, []) as $subject)
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-3 py-3 dark:border-gray-800 dark:bg-gray-900/50">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ data_get($subject, 'displayName') }}</div>
@if (data_get($subject, 'preflight.reasonLabels', []) !== [])
<div class="mt-2 flex flex-wrap gap-2">
@foreach (data_get($subject, 'preflight.reasonLabels', []) as $reasonLabel)
<x-filament::badge color="gray" size="sm">{{ $reasonLabel }}</x-filament::badge>
@endforeach
</div>
@endif
</div>
@empty
<div class="text-sm text-gray-500 dark:text-gray-400">No governed subjects in this bucket.</div>
@endforelse
</div>
</div>
@endforeach
</div>
</div>
</x-filament::section>
@endif
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@ -18,7 +18,7 @@
</h1> </h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300"> <p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
</p> </p>
</div> </div>
@ -161,4 +161,4 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
</x-filament::section> </x-filament::section>
@endforeach @endforeach
@endif @endif
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Concerns;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
trait BuildsPortfolioCompareFixtures
{
/**
* @return array{user: User, workspace: Workspace, sourceTenant: Tenant, targetTenant: Tenant}
*/
protected function makeCrossTenantCompareFixture(
string $workspaceRole = 'owner',
string $tenantRole = 'owner',
): array {
$sourceTenant = Tenant::factory()->create([
'name' => 'Source Tenant',
]);
[$user, $sourceTenant] = createUserWithTenant(
tenant: $sourceTenant,
role: $tenantRole,
workspaceRole: $workspaceRole,
);
$workspace = Workspace::query()->findOrFail((int) $sourceTenant->workspace_id);
$targetTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Target Tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $targetTenant->getKey() => ['role' => $tenantRole],
]);
app(CapabilityResolver::class)->clearCache();
app(WorkspaceCapabilityResolver::class)->clearCache();
return [
'user' => $user,
'workspace' => $workspace,
'sourceTenant' => $sourceTenant,
'targetTenant' => $targetTenant,
];
}
/**
* @return array{0: string, 1: array<string, mixed>}
*/
protected function setAdminWorkspaceContext(User $user, Workspace $workspace): array
{
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()];
}
/**
* @param array<string, mixed> $snapshot
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
*/
protected function createPortfolioCompareSubject(
Tenant $tenant,
string $displayName,
array $snapshot,
string $policyType = 'deviceConfiguration',
?string $externalId = null,
): array {
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
'display_name' => $displayName,
'platform' => 'windows',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_type' => $policyType,
'platform' => 'windows',
'captured_at' => now(),
'snapshot' => $snapshot,
'assignments' => [],
'scope_tags' => [],
]);
$inventory = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => (string) $policy->external_id,
'display_name' => $displayName,
'last_seen_at' => now(),
]);
return [
'policy' => $policy,
'version' => $version,
'inventory' => $inventory,
];
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps findings intake secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->create([
'workspace_id' => (int) $tenant->workspace_id,
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'intake-from-governance',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'intake_findings',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'intake_findings',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'view' => 'needs_triage',
]))
->actingAs($user)
->test(FindingsIntakeQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([$finding])
->assertSee('Shared unassigned work')
->assertDontSee('This workspace decision surface routes you');
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps my findings secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'assigned-from-governance',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'assigned_findings',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'assigned_findings',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
]))
->actingAs($user)
->test(MyFindingsInbox::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([Finding::query()->where('subject_external_id', 'assigned-from-governance')->firstOrFail()])
->assertSee('Assigned to me only')
->assertDontSee('This workspace decision surface routes you');
});

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
it('launches the finding exceptions lane with tenant and family return context', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'governance-exception-lane',
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance convergence request',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions');
$response->assertOk()
->assertSee('Finding exceptions')
->assertSee('Open finding exceptions')
->assertSee('Governance convergence request')
->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false)
->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false)
->assertSee('exception='.(string) $exception->getKey(), false)
->assertDontSee('Open my findings')
->assertDontSee('Open findings intake');
});

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -46,6 +47,28 @@
->reopened() ->reopened()
->create(); ->create();
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'subject_external_id' => 'exception-governance-home',
]);
FindingException::query()->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance home exception review',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory() OperationRun::factory()
->forTenant($alphaTenant) ->forTenant($alphaTenant)
->create([ ->create([
@ -87,13 +110,15 @@
->assertOk() ->assertOk()
->assertSee('Assigned findings') ->assertSee('Assigned findings')
->assertSee('Findings intake') ->assertSee('Findings intake')
->assertSee('Finding exceptions')
->assertSee('Operations follow-up') ->assertSee('Operations follow-up')
->assertSee('Alert delivery failures') ->assertSee('Alert delivery failures')
->assertSee('Review follow-up') ->assertSee('Review follow-up')
->assertSee('Open my findings') ->assertSee('Open my findings')
->assertSee('Open finding exceptions')
->assertSee('Open terminal follow-up') ->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries') ->assertSee('Open alert deliveries')
->assertSee('Open review follow-up'); ->assertSee('Open customer review workspace');
}); });
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
@ -140,4 +165,48 @@
->assertSee('Alert delivery failures') ->assertSee('Alert delivery failures')
->assertSee('No failed alert deliveries match this tenant filter right now.') ->assertSee('No failed alert deliveries match this tenant filter right now.')
->assertDontSee('Open my findings'); ->assertDontSee('Open my findings');
}); });
it('omits the finding exceptions lane when the workspace capability is not visible', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exceptionFinding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Hidden exception lane',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Assigned findings')
->assertDontSee('Finding exceptions')
->assertDontSee('Hidden exception lane');
});

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps the finding exceptions queue secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'exception-secondary-finding',
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Exception queue return context',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'finding_exceptions',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'finding_exceptions',
]),
);
$component = Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'exception' => (int) $exception->getKey(),
]))
->actingAs($user)
->test(FindingExceptionsQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertActionVisible('open_selected_exception')
->assertActionVisible('open_selected_finding')
->assertSee('Exception queue return context')
->assertSee('Focused review lane')
->assertDontSee('This workspace decision surface routes you');
expect($component->instance()->selectedExceptionUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
expect($component->instance()->selectedFindingUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
});

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('returns 404 for non-members on the cross-tenant compare route', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CrossTenantComparePage::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members missing baseline view capability on the compare route', function (): void {
$workspace = Workspace::factory()->create();
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'readonly',
]);
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
$this->actingAs($viewer)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CrossTenantComparePage::getUrl(panel: 'admin'))
->assertForbidden();
});
it('returns 404 when the requested target tenant is outside the actor scope', function (): void {
$fixture = $this->makeCrossTenantCompareFixture();
$hiddenTarget = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Hidden Target',
]);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(CrossTenantComparePage::getUrl(parameters: [
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'target_tenant_id' => (int) $hiddenTarget->getKey(),
], panel: 'admin'))
->assertNotFound();
});
it('keeps promotion preflight visible but disabled for readonly members and forbids forced execution', function (): void {
$fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'readonly', tenantRole: 'readonly');
$this->createPortfolioCompareSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Readonly Policy',
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
);
$this->createPortfolioCompareSubject(
tenant: $fixture['targetTenant'],
displayName: 'Readonly Policy',
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
);
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$query = [
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
'policy_type' => ['deviceConfiguration'],
];
Livewire::withQueryParams($query)
->actingAs($fixture['user'])
->test(CrossTenantComparePage::class)
->assertActionVisible('generatePromotionPreflight')
->assertActionDisabled('generatePromotionPreflight')
->assertActionExists('generatePromotionPreflight', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to generate a promotion preflight.')
->call('generatePromotionPreflight')
->assertForbidden();
});

View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class);
function crossTenantCompareLaunchQuery(string $url): array
{
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return $query;
}
it('launches cross-tenant compare from the tenant registry with target prefill and return context', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
$backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
$triageState = $this->portfolioReturnFilters(
[TenantBackupHealthAssessment::POSTURE_STALE],
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
[],
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
);
$expectedUrl = TenantResource::crossTenantCompareOpenUrl($targetTenant, $triageState);
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
->assertTableActionVisible('compareTenants', $targetTenant)
->assertTableActionHasUrl('compareTenants', $expectedUrl, $targetTenant);
$query = crossTenantCompareLaunchQuery($expectedUrl);
$backUrl = urldecode((string) data_get($query, 'nav.back_url'));
expect($query)->toMatchArray([
'target_tenant_id' => (string) $targetTenant->getKey(),
])
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
->and(data_get($query, 'nav.tenant_id'))->toBe((string) $targetTenant->getKey())
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry')
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
Livewire::withQueryParams($query)
->actingAs($user)
->test(CrossTenantComparePage::class)
->assertSet('sourceTenantId', null)
->assertSet('targetTenantId', (string) $targetTenant->getKey())
->assertActionVisible('return_to_origin')
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
&& $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState));
});
it('launches cross-tenant compare from an exact-two bulk selection with both tenants prefilled', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
$anchorBackupSet = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($anchorTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $anchorBackupSet);
$backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
$triageState = $this->portfolioReturnFilters(
[TenantBackupHealthAssessment::POSTURE_STALE],
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
[],
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
);
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
targetTenant: $targetTenant,
triageState: $triageState,
sourceTenant: $anchorTenant,
);
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
->selectTableRecords([$anchorTenant, $targetTenant])
->assertTableBulkActionVisible('compareSelected')
->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant])
->assertRedirect($expectedUrl);
$query = crossTenantCompareLaunchQuery($expectedUrl);
$backUrl = urldecode((string) data_get($query, 'nav.back_url'));
expect($query)->toMatchArray([
'source_tenant_id' => (string) $anchorTenant->getKey(),
'target_tenant_id' => (string) $targetTenant->getKey(),
])
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry')
->and(data_get($query, 'nav.tenant_id'))->toBeNull()
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
Livewire::withQueryParams($query)
->actingAs($user)
->test(CrossTenantComparePage::class)
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
->assertSet('targetTenantId', (string) $targetTenant->getKey())
->assertActionVisible('return_to_origin');
});
it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
$thirdTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Third Tenant');
$this->portfolioTriageRegistryList($user, $anchorTenant)
->selectTableRecords([$anchorTenant])
->assertTableBulkActionVisible('compareSelected')
->callTableBulkAction('compareSelected', [$anchorTenant])
->assertNotified('Select exactly two tenants to compare.');
$this->portfolioTriageRegistryList($user, $anchorTenant)
->selectTableRecords([$anchorTenant, $targetTenant, $thirdTenant])
->assertTableBulkActionVisible('compareSelected')
->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant, $thirdTenant])
->assertNotified('Select exactly two tenants to compare.');
});
it('rejects the bulk compare action when a selected tenant is not active', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$onboardingTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Onboarding Tenant');
$onboardingTenant->forceFill([
'status' => Tenant::STATUS_ONBOARDING,
])->save();
$this->portfolioTriageRegistryList($user, $anchorTenant)
->selectTableRecords([$anchorTenant, $onboardingTenant])
->assertTableBulkActionVisible('compareSelected')
->callTableBulkAction('compareSelected', [$anchorTenant, $onboardingTenant])
->assertNotified('Only active tenants can be compared.');
});
it('hides the compare launch action when workspace baseline view capability is missing', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
$this->portfolioTriageRegistryList($user, $anchorTenant)
->assertTableActionHidden('compareTenants', $targetTenant);
});
it('hides the compare launch action when the actor lacks tenant view on the launched tenant', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('primeMemberships')->andReturnNull();
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnUsing(function (mixed $actor, mixed $tenant, string $capability) use ($targetTenant): bool {
if ($tenant instanceof Tenant
&& (int) $tenant->getKey() === (int) $targetTenant->getKey()
&& $capability === Capabilities::TENANT_VIEW) {
return false;
}
return true;
});
app()->instance(CapabilityResolver::class, $resolver);
$this->portfolioTriageRegistryList($user, $anchorTenant)
->assertTableActionHidden('compareTenants', $targetTenant);
});

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Resources\TenantResource;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('renders a reproducible compare preview and promotion preflight for two authorized tenants', function (): void {
$fixture = $this->makeCrossTenantCompareFixture();
$this->createPortfolioCompareSubject(
tenant: $fixture['sourceTenant'],
displayName: 'WiFi Corp',
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
);
$this->createPortfolioCompareSubject(
tenant: $fixture['targetTenant'],
displayName: 'WiFi Corp',
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
);
$this->createPortfolioCompareSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Windows Compliance',
snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]],
);
$this->createPortfolioCompareSubject(
tenant: $fixture['targetTenant'],
displayName: 'Windows Compliance',
snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]],
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$query = [
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
'policy_type' => ['deviceConfiguration'],
];
$this->withSession($session)
->get(CrossTenantComparePage::getUrl(parameters: $query, panel: 'admin'))
->assertOk()
->assertSee('Cross-tenant compare')
->assertSee('Compare preview')
->assertSee('WiFi Corp')
->assertSee('Windows Compliance')
->assertSee('Source tenant: '.$fixture['sourceTenant']->name)
->assertSee('Target tenant: '.$fixture['targetTenant']->name)
->assertSee(TenantResource::getUrl('view', ['record' => $fixture['sourceTenant']], panel: 'admin'), false)
->assertSee(TenantResource::getUrl('view', ['record' => $fixture['targetTenant']], panel: 'admin'), false);
Livewire::withQueryParams($query)
->actingAs($fixture['user'])
->test(CrossTenantComparePage::class)
->assertActionVisible('generatePromotionPreflight')
->assertActionEnabled('generatePromotionPreflight')
->call('generatePromotionPreflight')
->assertHasNoErrors()
->assertSee('Promotion preflight')
->assertSee('WiFi Corp')
->assertSee('Windows Compliance');
});
it('rejects the same tenant as source and target without rendering compare results', function (): void {
$fixture = $this->makeCrossTenantCompareFixture();
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(CrossTenantComparePage::getUrl(parameters: [
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'target_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
], panel: 'admin'))
->assertOk()
->assertSee('Choose two different tenants.')
->assertDontSee('data-testid="cross-tenant-compare-preview"', false)
->assertDontSee('Promotion preflight');
});

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('audits promotion preflight generation without creating writes or operation runs', function (): void {
$fixture = $this->makeCrossTenantCompareFixture();
$this->createPortfolioCompareSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Audit Policy',
snapshot: ['settings' => [['key' => 'audit', 'value' => 1]]],
);
$this->createPortfolioCompareSubject(
tenant: $fixture['targetTenant'],
displayName: 'Audit Policy',
snapshot: ['settings' => [['key' => 'audit', 'value' => 2]]],
);
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$operationRunCount = OperationRun::query()->count();
$policyVersionCount = PolicyVersion::query()->count();
Livewire::withQueryParams([
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
'policy_type' => ['deviceConfiguration'],
])
->actingAs($fixture['user'])
->test(CrossTenantComparePage::class)
->call('generatePromotionPreflight')
->assertHasNoErrors();
$audit = AuditLog::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('action', AuditActionId::CrossTenantPromotionPreflightGenerated->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->status)->toBe('success')
->and($audit?->resource_type)->toBe('cross_tenant_promotion_preflight')
->and(data_get($audit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey())
->and(data_get($audit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey())
->and(data_get($audit?->metadata, 'ready_count'))->toBe(1)
->and(data_get($audit?->metadata, 'blocked_count'))->toBe(0)
->and(data_get($audit?->metadata, 'manual_mapping_required_count'))->toBe(0);
expect(OperationRun::query()->count())->toBe($operationRunCount)
->and(PolicyVersion::query()->count())->toBe($policyVersionCount);
});

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps the customer review workspace secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now(),
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'review_follow_up',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'review_follow_up',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
]))
->actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([$tenant->fresh()])
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY.'=1', false)
->assertSee('nav%5Bsource_surface%5D=governance.inbox', false)
->assertSee('nav%5Bfamily_key%5D=review_follow_up', false)
->assertDontSee('This workspace decision surface routes you');
});

View File

@ -52,3 +52,26 @@
->and($context?->backLinkLabel)->toBe('Back to backup set') ->and($context?->backLinkLabel)->toBe('Back to backup set')
->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8'); ->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8');
}); });
it('serializes governance inbox family context for secondary surface return links', function (): void {
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: 'filament.admin.pages.governance.inbox',
tenantId: 12,
familyKey: 'finding_exceptions',
backLinkUrl: '/admin/governance/inbox?tenant_id=12&family=finding_exceptions',
);
$roundTrip = CanonicalNavigationContext::fromRequest(Request::create('/admin/finding-exceptions/queue', 'GET', $context->toQuery()));
expect($context->toQuery()['nav'])
->toMatchArray([
'source_surface' => 'governance.inbox',
'tenant_id' => 12,
'family_key' => 'finding_exceptions',
'back_label' => 'Back to governance inbox',
])
->and($roundTrip?->sourceSurface)->toBe('governance.inbox')
->and($roundTrip?->tenantId)->toBe(12)
->and($roundTrip?->familyKey)->toBe('finding_exceptions')
->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?tenant_id=12&family=finding_exceptions');
});

View File

@ -4,6 +4,7 @@
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -54,6 +55,28 @@
'subject_external_id' => 'intake-finding', 'subject_external_id' => 'intake-finding',
]); ]);
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $workspace->getKey(),
'subject_external_id' => 'exception-finding',
]);
FindingException::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Needs approval',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory() OperationRun::factory()
->forTenant($alphaTenant) ->forTenant($alphaTenant)
->create([ ->create([
@ -129,6 +152,7 @@
visibleFindingTenants: [$alphaTenant, $bravoTenant], visibleFindingTenants: [$alphaTenant, $bravoTenant],
reviewTenants: [$alphaTenant, $bravoTenant], reviewTenants: [$alphaTenant, $bravoTenant],
canViewAlerts: true, canViewAlerts: true,
canViewFindingExceptions: true,
navigationContext: $context, navigationContext: $context,
); );
@ -136,6 +160,7 @@
->toBe([ ->toBe([
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -143,6 +168,7 @@
->and($payload['family_counts'])->toMatchArray([ ->and($payload['family_counts'])->toMatchArray([
'assigned_findings' => 1, 'assigned_findings' => 1,
'intake_findings' => 1, 'intake_findings' => 1,
'finding_exceptions' => 1,
'stale_operations' => 2, 'stale_operations' => 2,
'alert_delivery_failures' => 1, 'alert_delivery_failures' => 1,
'review_follow_up' => 2, 'review_follow_up' => 2,
@ -153,6 +179,9 @@
expect($sections['assigned_findings']['dominant_action_url']) expect($sections['assigned_findings']['dominant_action_url'])
->toContain('/admin/findings/my-work') ->toContain('/admin/findings/my-work')
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
->and($sections['finding_exceptions']['dominant_action_label'])->toBe('Open finding exceptions')
->and($sections['finding_exceptions']['dominant_action_url'])->toContain('/admin/finding-exceptions/queue')
->and($sections['finding_exceptions']['entries'][0]['destination_url'])->toContain('exception='.(string) $sections['finding_exceptions']['entries'][0]['source_key'])
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
@ -196,4 +225,64 @@
->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures') ->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures')
->and($payload['sections'][0]['count'])->toBe(0) ->and($payload['sections'][0]['count'])->toBe(0)
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
}); });
it('omits finding exceptions when the exception family is hidden or tenant scope is inaccessible', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$visibleTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Visible Tenant',
]);
$hiddenTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Hidden Tenant',
]);
$finding = Finding::factory()
->for($hiddenTenant)
->riskAccepted()
->create(['workspace_id' => (int) $workspace->getKey()]);
FindingException::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $hiddenTenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Hidden request',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$builder = app(GovernanceInboxSectionBuilder::class);
$payloadWithoutCapability = $builder->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$visibleTenant, $hiddenTenant],
visibleFindingTenants: [],
reviewTenants: [],
canViewAlerts: false,
canViewFindingExceptions: false,
);
$payloadWithHiddenTenantOnly = $builder->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$visibleTenant],
visibleFindingTenants: [],
reviewTenants: [],
canViewAlerts: false,
canViewFindingExceptions: true,
);
expect(collect($payloadWithoutCapability['available_families'])->pluck('key')->all())
->not->toContain('finding_exceptions')
->and($payloadWithHiddenTenantOnly['family_counts']['finding_exceptions'] ?? null)->toBe(0)
->and($payloadWithHiddenTenantOnly['sections'])->toBe([]);
});

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds stable compare states for matching, differing, and missing subjects', function (): void {
$fixture = crossTenantCompareFixture();
createComparedPolicy(
tenant: $fixture['sourceTenant'],
displayName: 'WiFi Corp',
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
);
createComparedPolicy(
tenant: $fixture['targetTenant'],
displayName: 'WiFi Corp',
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
);
createComparedPolicy(
tenant: $fixture['sourceTenant'],
displayName: 'Windows Compliance',
snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]],
);
createComparedPolicy(
tenant: $fixture['targetTenant'],
displayName: 'Windows Compliance',
snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]],
);
createComparedPolicy(
tenant: $fixture['sourceTenant'],
displayName: 'VPN Profile',
snapshot: ['settings' => [['key' => 'vpn', 'value' => 1]]],
);
$selection = new CrossTenantCompareSelection(
sourceTenant: $fixture['sourceTenant'],
targetTenant: $fixture['targetTenant'],
policyTypes: ['deviceConfiguration'],
);
$builder = app(CrossTenantComparePreviewBuilder::class);
$preview = $builder->build($selection);
expect($preview['summary'])->toBe([
'match' => 1,
'different' => 1,
'missing' => 1,
'ambiguous' => 0,
'blocked' => 0,
'total' => 3,
]);
$subjects = collect($preview['subjects'])->keyBy('displayName');
expect($subjects->get('WiFi Corp'))->not->toBeNull()
->and($subjects->get('WiFi Corp')['state'])->toBe('match')
->and(data_get($subjects->get('WiFi Corp'), 'source.evidence.fidelity'))->toBe('content')
->and(data_get($subjects->get('WiFi Corp'), 'target.evidence.fidelity'))->toBe('content')
->and($subjects->get('Windows Compliance')['state'])->toBe('different')
->and($subjects->get('VPN Profile')['state'])->toBe('missing');
expect($builder->build($selection))->toBe($preview);
});
it('marks unresolved source identity and duplicate target matches distinctly', function (): void {
$fixture = crossTenantCompareFixture();
InventoryItem::factory()->create([
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'policy_type' => 'deviceConfiguration',
'display_name' => ' ',
'external_id' => 'source-without-identifier',
]);
createComparedPolicy(
tenant: $fixture['sourceTenant'],
displayName: 'Duplicated Policy',
snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]],
);
createComparedPolicy(
tenant: $fixture['targetTenant'],
displayName: 'Duplicated Policy',
externalId: 'dup-target-1',
snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]],
);
createComparedPolicy(
tenant: $fixture['targetTenant'],
displayName: 'Duplicated Policy',
externalId: 'dup-target-2',
snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]],
);
$preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection(
sourceTenant: $fixture['sourceTenant'],
targetTenant: $fixture['targetTenant'],
policyTypes: ['deviceConfiguration'],
));
expect($preview['summary'])->toBe([
'match' => 0,
'different' => 0,
'missing' => 0,
'ambiguous' => 1,
'blocked' => 1,
'total' => 2,
]);
$identifierGap = collect($preview['subjects'])
->first(fn (array $subject): bool => in_array('source_identifier_missing', $subject['reasonCodes'], true));
$ambiguousTarget = collect($preview['subjects'])
->first(fn (array $subject): bool => in_array('target_subject_ambiguous', $subject['reasonCodes'], true));
expect($identifierGap)->toBeArray()
->and($identifierGap['state'])->toBe('blocked')
->and($identifierGap['trustLevel'])->toBe('unusable')
->and($ambiguousTarget)->toBeArray()
->and($ambiguousTarget['state'])->toBe('ambiguous')
->and($ambiguousTarget['trustLevel'])->toBe('diagnostic_only');
});
/**
* @return array{sourceTenant: Tenant, targetTenant: Tenant}
*/
function crossTenantCompareFixture(): array
{
$sourceTenant = Tenant::factory()->create();
$targetTenant = Tenant::factory()->create([
'workspace_id' => (int) $sourceTenant->workspace_id,
]);
return [
'sourceTenant' => $sourceTenant,
'targetTenant' => $targetTenant,
];
}
/**
* @param array<string, mixed> $snapshot
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
*/
function createComparedPolicy(
Tenant $tenant,
string $displayName,
array $snapshot,
string $policyType = 'deviceConfiguration',
?string $externalId = null,
): array {
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
'display_name' => $displayName,
'platform' => 'windows',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_type' => $policyType,
'platform' => 'windows',
'captured_at' => now(),
'snapshot' => $snapshot,
'assignments' => [],
'scope_tags' => [],
]);
$inventory = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => (string) $policy->external_id,
'display_name' => $displayName,
'last_seen_at' => now(),
]);
return [
'policy' => $policy,
'version' => $version,
'inventory' => $inventory,
];
}

View File

@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('classifies ready, blocked, and manual mapping subjects from the compare preview', function (): void {
$fixture = crossTenantPromotionFixture();
createPromotionSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Aligned Policy',
snapshot: ['settings' => [['key' => 'aligned', 'value' => 1]]],
);
createPromotionSubject(
tenant: $fixture['targetTenant'],
displayName: 'Aligned Policy',
snapshot: ['settings' => [['key' => 'aligned', 'value' => 1]]],
);
createPromotionSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Different Policy',
snapshot: ['settings' => [['key' => 'different', 'value' => 1]]],
);
createPromotionSubject(
tenant: $fixture['targetTenant'],
displayName: 'Different Policy',
snapshot: ['settings' => [['key' => 'different', 'value' => 2]]],
);
createPromotionSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Missing Policy',
snapshot: ['settings' => [['key' => 'missing', 'value' => 1]]],
);
createPromotionSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Manual Mapping Policy',
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
);
createPromotionSubject(
tenant: $fixture['targetTenant'],
displayName: 'Manual Mapping Policy',
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
externalId: 'manual-target-1',
);
createPromotionSubject(
tenant: $fixture['targetTenant'],
displayName: 'Manual Mapping Policy',
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
externalId: 'manual-target-2',
);
InventoryItem::factory()->create([
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'policy_type' => 'deviceConfiguration',
'display_name' => ' ',
'external_id' => 'missing-source-identifier',
]);
Policy::factory()->create([
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => 'meta-only-source',
'display_name' => 'Refresh Required Policy',
'platform' => 'windows',
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => 'meta-only-source',
'display_name' => 'Refresh Required Policy',
'meta_jsonb' => ['etag' => 'source-meta-only'],
]);
Policy::factory()->create([
'tenant_id' => (int) $fixture['targetTenant']->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => 'meta-only-target',
'display_name' => 'Refresh Required Policy',
'platform' => 'windows',
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $fixture['targetTenant']->getKey(),
'policy_type' => 'deviceConfiguration',
'external_id' => 'meta-only-target',
'display_name' => 'Refresh Required Policy',
'meta_jsonb' => ['etag' => 'target-meta-only'],
]);
$selection = new CrossTenantCompareSelection(
sourceTenant: $fixture['sourceTenant'],
targetTenant: $fixture['targetTenant'],
policyTypes: ['deviceConfiguration'],
);
$preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
$preflight = app(CrossTenantPromotionPreflight::class)->build($preview);
expect($preflight['summary'])->toBe([
'ready' => 3,
'blocked' => 2,
'manual_mapping_required' => 1,
'total' => 6,
]);
$bucketByName = collect($preflight['buckets']['ready'])
->merge($preflight['buckets']['blocked'])
->merge($preflight['buckets']['manual_mapping_required'])
->mapWithKeys(static fn (array $subject): array => [
(string) ($subject['displayName'] ?? '') => $subject['preflight'],
]);
expect(data_get($bucketByName, 'Aligned Policy.bucket'))->toBe('ready')
->and(data_get($bucketByName, 'Different Policy.bucket'))->toBe('ready')
->and(data_get($bucketByName, 'Missing Policy.bucket'))->toBe('ready')
->and(data_get($bucketByName, 'Manual Mapping Policy.bucket'))->toBe('manual_mapping_required')
->and(data_get($bucketByName, 'Refresh Required Policy.bucket'))->toBe('blocked');
$identifierGap = collect($preflight['buckets']['blocked'])
->first(fn (array $subject): bool => in_array('source_identifier_missing', data_get($subject, 'preflight.reasonCodes', []), true));
expect($identifierGap)->toBeArray()
->and(data_get($identifierGap, 'preflight.reasonLabels.0'))->toBe('Source tenant subject is missing a stable compare identifier.')
->and($preflight['blockedReasonCounts'])->toMatchArray([
'source_identifier_missing' => 1,
'source_evidence_refresh_required' => 1,
'target_subject_ambiguous' => 1,
]);
});
it('remains read only when building a promotion preflight', function (): void {
$fixture = crossTenantPromotionFixture();
createPromotionSubject(
tenant: $fixture['sourceTenant'],
displayName: 'Readonly Policy',
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
);
$preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection(
sourceTenant: $fixture['sourceTenant'],
targetTenant: $fixture['targetTenant'],
policyTypes: ['deviceConfiguration'],
));
$operationRunCount = OperationRun::query()->count();
$policyVersionCount = PolicyVersion::query()->count();
app(CrossTenantPromotionPreflight::class)->build($preview);
expect(OperationRun::query()->count())->toBe($operationRunCount)
->and(PolicyVersion::query()->count())->toBe($policyVersionCount);
});
/**
* @return array{sourceTenant: Tenant, targetTenant: Tenant}
*/
function crossTenantPromotionFixture(): array
{
$sourceTenant = Tenant::factory()->create();
$targetTenant = Tenant::factory()->create([
'workspace_id' => (int) $sourceTenant->workspace_id,
]);
return [
'sourceTenant' => $sourceTenant,
'targetTenant' => $targetTenant,
];
}
/**
* @param array<string, mixed> $snapshot
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
*/
function createPromotionSubject(
Tenant $tenant,
string $displayName,
array $snapshot,
string $policyType = 'deviceConfiguration',
?string $externalId = null,
): array {
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
'display_name' => $displayName,
'platform' => 'windows',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_type' => $policyType,
'platform' => 'windows',
'captured_at' => now(),
'snapshot' => $snapshot,
'assignments' => [],
'scope_tags' => [],
]);
$inventory = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => (string) $policy->external_id,
'display_name' => $displayName,
'last_seen_at' => now(),
]);
return [
'policy' => $policy,
'version' => $version,
'inventory' => $inventory,
];
}

View File

@ -1,7 +1,7 @@
# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight # Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop **Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
**Created**: 2026-04-27 **Created**: 2026-04-27
**Feature**: [spec.md](../spec.md) **Feature**: [spec.md](../spec.md)
## Content Quality ## Content Quality
@ -54,4 +54,6 @@ ## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists. - This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists.
- The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation. - The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation.
- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets. - No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets.
- Implementation sync on 2026-04-30 confirmed the code still honors those guardrails: the landed slice remains read-only, adds no compare resource to global search, and introduces no new asset registration.
- TEST-GOV-001 close-out for the landed slice stays `keep`: focused `Unit` + `Feature` proof only, with actual execution, mapping automation, and multi-provider compare explicitly deferred as follow-up work rather than hidden scope growth.

View File

@ -1,6 +1,6 @@
# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight # Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md) **Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md) **Input**: Feature specification from [spec.md](spec.md)
## Summary ## Summary
@ -9,22 +9,45 @@ ## Summary
Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected. Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected.
## Implementation Sync
- Landed runtime artifacts:
- `App\Filament\Pages\CrossTenantComparePage`
- `App\Support\PortfolioCompare\CrossTenantCompareSelection`
- `App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder`
- `App\Support\PortfolioCompare\CrossTenantPromotionPreflight`
- tenant-registry row launch, exact-two bulk launch, and return wiring in `TenantResource` and `CanonicalNavigationContext`
- bounded preflight audit logging in `WorkspaceAuditLogger` and `AuditActionId`
- Landed validation artifacts:
- focused `Unit/Support/PortfolioCompare` tests for compare preview and promotion preflight
- focused `Feature/PortfolioCompare` tests for page rendering, auth semantics, audit semantics, and registry launch continuity
- Confirmed implementation constraints:
- read-only only; no target mutation, queue, or `OperationRun`
- no new asset registration
- no new globally searchable resource
- admin panel provider registration remains unchanged outside explicit page registration in Filament's admin panel provider
- Deferred follow-up remains unchanged:
- actual promotion execution
- persisted promotion drafts or compare snapshots
- mapping automation
- multi-provider compare
## Technical Context ## Technical Context
**Language/Version**: PHP 8.4, Laravel 12 **Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers **Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table **Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
**Testing**: Pest v4 `Unit` and `Feature` coverage only **Testing**: Pest v4 `Unit` and `Feature` coverage only
**Validation Lanes**: fast-feedback, confidence **Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) **Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
**Project Type**: Web application (Laravel monolith with Filament pages) **Project Type**: Web application (Laravel monolith with Filament pages)
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1 **Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default **Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders **Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders
## UI / Surface Guardrail Plan ## UI / Surface Guardrail Plan
- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context - **Guardrail scope**: one new canonical compare page plus bounded row and exact-two bulk launch actions from existing tenant-registry/portfolio context
- **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives - **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives
- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy - **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy
- **State layers in scope**: page, query state - **State layers in scope**: page, query state
@ -32,7 +55,7 @@ ## UI / Surface Guardrail Plan
- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces - **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces
- **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages - **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages
- **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary - **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary
- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly - **Launch default**: the row launch prefills the launched tenant as `target tenant`; the exact-two bulk launch prefills both selected tenants while preserving the same registry return context
- **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope - **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope
- **Repository-signal treatment**: review-mandatory - **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament - **Special surface test profiles**: standard-native-filament

View File

@ -1,11 +1,21 @@
# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight # Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
**Feature Branch**: `043-cross-tenant-compare-and-promotion` **Feature Branch**: `043-cross-tenant-compare-and-promotion`
**Created**: 2026-01-07 **Created**: 2026-01-07
**Updated**: 2026-04-27 **Updated**: 2026-04-30
**Status**: Ready for implementation **Status**: Implemented (read-only slice)
**Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition. **Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition.
## Implementation Sync *(2026-04-30)*
- The canonical admin compare surface is implemented as `CrossTenantComparePage` under `/admin/cross-tenant-compare` with shareable query state, direct tenant drill-down links, and one dominant read-only action: `Generate promotion preflight`.
- The reusable compare contract is implemented in `App\Support\PortfolioCompare\CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight`.
- Portfolio launch continuity is implemented from the tenant registry via a bounded row-level `Compare tenants` action, an exact-two bulk compare launch, and `CanonicalNavigationContext` return-state wiring.
- Preflight audit is implemented through the existing workspace audit pipeline using `AuditActionId::CrossTenantPromotionPreflightGenerated` and `WorkspaceAuditLogger`.
- The focused `Unit` + `Feature` PortfolioCompare suite is green for compare preview, preflight, authorization, audit, and launch/return continuity.
- Explicitly deferred and still out of scope: actual promotion execution, target mutation, queueing, `OperationRun`, persisted compare snapshots, persisted promotion drafts, mapping automation, customer-facing compare, and multi-provider compare.
- Guardrails remain unchanged in implementation: Filament v5 on Livewire v4, provider registration stays in `bootstrap/providers.php`, no globally searchable compare resource was introduced, and no new asset registration was added.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)* ## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision. - **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision.
@ -98,7 +108,7 @@ ## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are chang
| 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 | | 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 |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker | | Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker |
| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row | | Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` / `Compare selected` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* ## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
@ -112,7 +122,7 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | | Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none | | Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none |
| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none | | Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenants should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants / Compare selected | none |
## Proportionality Review *(mandatory when structural complexity is introduced)* ## Proportionality Review *(mandatory when structural complexity is introduced)*
@ -242,7 +252,7 @@ ### User Story 3 - Launch compare from portfolio context without losing return s
**Acceptance Scenarios**: **Acceptance Scenarios**:
1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`. 1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or an exact-two bulk selection, **Then** the compare page preserves a return token and prefills the launched tenant context without dropping the current registry filters.
2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored. 2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored.
### Edge Cases ### Edge Cases

View File

@ -6,31 +6,31 @@
# Tasks: Cross-Tenant Compare Preview and Promotion Preflight # Tasks: Cross-Tenant Compare Preview and Promotion Preflight
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/` **Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required) **Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required)
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice. **Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice.
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only. **Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only.
**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`. **RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`.
**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood. **Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood.
**Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist. **Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist.
## Test Governance Checklist ## Test Governance Checklist
- [ ] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. - [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [ ] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only. - [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only.
- [ ] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history. - [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history.
- [ ] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope. - [x] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope.
- [ ] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces. - [x] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces.
- [ ] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth. - [x] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth.
## Phase 1: Setup (Shared Context) ## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins. **Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins.
- [ ] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references. - [x] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references.
- [ ] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`. - [x] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`.
- [ ] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`. - [x] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`.
--- ---
@ -40,11 +40,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**Critical**: No user-story work should begin until this phase is complete. **Critical**: No user-story work should begin until this phase is complete.
- [ ] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework. - [x] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework.
- [ ] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics. - [x] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics.
- [ ] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth. - [x] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth.
- [ ] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation. - [x] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation.
- [ ] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only. - [x] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only.
**Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently. **Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently.
@ -58,15 +58,15 @@ ## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP
### Tests for User Story 1 ### Tests for User Story 1
- [ ] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. - [x] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
- [ ] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. - [x] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
- [ ] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`. - [x] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`.
### Implementation for User Story 1 ### Implementation for User Story 1
- [ ] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder. - [x] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder.
- [ ] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics. - [x] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics.
- [ ] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary. - [x] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary.
**Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants. **Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants.
@ -80,15 +80,15 @@ ## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P
### Tests for User Story 2 ### Tests for User Story 2
- [ ] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. - [x] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
- [ ] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. - [x] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
- [ ] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`. - [x] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`.
### Implementation for User Story 2 ### Implementation for User Story 2
- [ ] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects. - [x] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects.
- [ ] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons. - [x] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons.
- [ ] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only. - [x] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only.
**Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page. **Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page.
@ -102,13 +102,13 @@ ## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing
### Tests for User Story 3 ### Tests for User Story 3
- [ ] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. - [x] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
- [ ] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. - [x] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
### Implementation for User Story 3 ### Implementation for User Story 3
- [ ] T023 [US3] Add a bounded launch action from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` that opens the canonical compare page with the current tenant prefilled as the `target tenant`. - [x] T023 [US3] Add bounded registry launch actions from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` so row launch can prefill the current tenant as the `target tenant` and exact-two bulk launch can prefill both selected tenants.
- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format. - [x] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format.
**Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters. **Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters.
@ -118,11 +118,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish narrow validation and reviewer close-out without widening scope. **Purpose**: Finish narrow validation and reviewer close-out without widening scope.
- [ ] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. - [x] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
- [ ] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. - [x] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
- [ ] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. - [x] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [ ] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource. - [x] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource.
- [ ] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes. - [x] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes.
--- ---

View File

@ -0,0 +1,37 @@
# Preparation Review Checklist: Governance Decision Surface Convergence v1
**Purpose**: Verify that the preparation package is repo-grounded, narrowly scoped, and ready for a later implementation loop.
**Created**: 2026-04-29
**Review outcome class**: Workflow Compression
**Workflow outcome**: approve for implementation
**Test-governance outcome**: keep
## Candidate Selection
- [x] CHK001 The selected slice is anchored to `docs/product/roadmap.md` (`Decision-Based Operating Foundations`) and the open-gap truth in `docs/product/implementation-ledger.md`.
- [x] CHK002 Already-specced candidates such as Spec 043, Spec 249, Spec 250, Spec 251, Spec 252, Spec 253, Spec 254, Spec 255, and Spec 256 are explicitly excluded from reopening.
- [x] CHK003 The package chooses the smallest repo-grounded slice: extend the existing governance inbox and specialist pages instead of inventing a new shell or workflow engine.
## Scope And Truth
- [x] CHK004 The spec, plan, and tasks all state that no new persistence, inbox-item truth, queue state, or mutation lane is introduced.
- [x] CHK005 The governance inbox remains the canonical workspace decision home, while specialist queues and the customer review workspace remain secondary-context surfaces.
- [x] CHK006 The finding-exceptions lane and review-consumption handoff are described as derived from existing repo truth rather than a new abstraction layer.
## UX And Authorization
- [x] CHK007 The package makes one dominant next action explicit for the governance home, preserves one dominant default action on each specialist surface, and keeps specialist pages from duplicating the workspace-level summary.
- [x] CHK008 `404` vs `403` semantics are explicit for workspace membership, tenant scope, and no-visible-family cases.
- [x] CHK009 Hidden tenants and hidden families are omitted from counts, labels, previews, and empty-state hints.
## Test Governance
- [x] CHK010 Planned proof stays in focused `Unit` plus `Feature` lanes only, with explicit validation commands.
- [x] CHK011 The tasks include explicit coverage for family assembly, arrival/return continuity, latest-published-review preference versus workspace fallback, duplicate-truth prevention, and read-only review integrity on the specialist pages.
- [x] CHK012 The checklist records the active review outcome class and workflow outcome instead of leaving readiness implicit.
## Readiness Outcome
- [x] CHK013 The package is ready for implementation only if analysis confirms that the scope remains bounded to existing governance, findings, monitoring, and review seams and that `CustomerReviewWorkspace` stays read-only.
- [x] CHK014 The package explicitly preserves the no-new-Graph-call, no-queue, no-`OperationRun`, and no-new-audit-stream constraints for this slice.
- [x] CHK015 Any broader dashboard-entry, cross-tenant, or workflow-engine follow-up is listed separately rather than hidden inside this slice.

View File

@ -0,0 +1,254 @@
# Implementation Plan: Governance Decision Surface Convergence v1
**Branch**: `257-governance-decision-convergence` | **Date**: 2026-04-29 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
## Summary
Tighten TenantPilot's decision-first operating model by converging onto the existing `GovernanceInbox` as the canonical workspace decision home, extending it with the still-missing finding-exceptions lane, and aligning the specialist findings, exceptions, and customer-review pages behind one truthful arrival and return model. The slice is intentionally read-only and reuses existing page, builder, and navigation seams instead of adding a new page shell, workflow state, or task engine.
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no new asset bundle is expected.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing governance inbox and navigation helpers
**Storage**: PostgreSQL via existing findings, finding-exceptions, reviews, packs, alerts, and operation-run truth only
**Testing**: Pest v4 `Unit` plus `Feature` coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
**Project Type**: Web application (Laravel monolith with Filament pages)
**Performance Goals**: derived DB-only page rendering, no new remote calls, and no queue or `OperationRun` start in v1
**Constraints**: no new persistence, no new page shell, no new mutation lane, no customer portal scope, no duplicate truth across equal-priority cards
**Scale/Scope**: 1 existing canonical page, 4 specialist page classes plus their Blade views, and 1 bounded section-builder extension
## Likely Affected Repo Surfaces
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
- `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/app/Support/Badges/BadgeRenderer.php`
## UI / Filament & Livewire Fit
- Reuse the existing `GovernanceInbox` Filament page instead of introducing a new page class or a utility shell.
- Keep the governance home as a section-based read-only page. Add one derived exception lane and adjust review-consumption handoff copy and arrival context, but keep diagnostics and proof on the existing specialist or detail routes.
- Preserve specialist-page ownership. `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` remain the pages where lane-specific truth and existing safe actions live.
- Any state that must survive navigation or Livewire requests stays on public, query-backed, or existing session-backed state. Do not move convergence state into private page properties.
- No new resource, global-search result, or panel asset registration is planned.
## RBAC / Policy Fit
- Workspace membership remains the first gate for the governance home and all converged routes.
- Findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW`; existing inline safe actions such as claim remain on the owning specialist pages and continue to require their existing capabilities such as `Capabilities::TENANT_FINDINGS_ASSIGN`.
- The exception lane must reuse the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE` rather than inventing a second exception-view capability.
- The review-consumption handoff must reuse the current review and review-pack access rules instead of adding a new customer-review-workspace capability family.
- `404` applies to non-members and out-of-scope tenant targets. `403` applies only to in-scope members who still cannot see any converged family.
## Audit / Logging Fit
- The convergence layer stays read-only and should not add a new page-view audit stream.
- Existing mutations and downloads remain audited on their current owning surfaces.
- No new `OperationRun`, notification stream, or navigation-event ledger is required.
## Data & Query Fit
- Extend `GovernanceInboxSectionBuilder` rather than creating a new persistence or projection layer.
- The new exception lane must derive from existing `FindingException` truth and the current queue semantics, not from a copied workflow summary.
- Review-consumption handoff should keep using the current latest-published-review vs customer-review-workspace fallback logic.
- Family counts, previews, and empty-state decisions must be computed only after tenant and capability filtering, so hidden tenants and hidden lanes do not leak through aggregate counts.
- Keep any new family key local to the page and builder; do not introduce a new domain enum or persisted state family.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: governance decision home, specialist queues, customer-review routing, navigation continuity
- **State layers in scope**: page, URL-query, table/session restore
- **Audience modes in scope**: operator-MSP, customer-read-only on the existing review workspace
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the governance home, diagnostics-second on specialist pages, raw/support detail remains on existing detail paths only
- **Raw/support gating plan**: hidden by default on the governance home; existing gating remains on source/detail surfaces
- **One-primary-action / duplicate-truth control**: governance home keeps one dominant CTA per section; specialist pages keep their existing lane-owned primary action and must not duplicate the governance-home summary
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the specialist findings, exception, and review pages listed above
- **Shared abstractions reused**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing source-page action-surface declarations
- **New abstraction introduced? why?**: at most one bounded convergence helper for arrival and return semantics if the existing navigation-context helper needs a thin extension; no new framework or registry is justified
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for page ownership and current routing, but not yet sufficient to make the governance home the single truthful start surface across the missing exception and review-consumption lanes
- **Bounded deviation / spread control**: no new shell or workflow engine; all implementation stays inside existing governance, findings, monitoring, reviews, and navigation seams
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` contract change
- **Central contract reused**: the already-existing stale-operations deep-link behavior remains unchanged
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: the governance home continues to list stale operations through the existing family, but this spec does not change that family's start or completion semantics
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing governance and navigation vocabulary only
- **Neutral platform terms / contracts preserved**: `Governance inbox`, `finding exceptions`, `customer review workspace`, `Open lane`, and `Back to governance inbox`
- **Retained provider-specific semantics and why**: none new
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before implementation preparation continues.*
- Inventory-first: PASS. All sections remain derived from existing findings, exceptions, reviews, alerts, and operation-run truth.
- Read/write separation: PASS. The convergence layer remains read-only and keeps mutations on existing source surfaces.
- Graph contract path: PASS. No new Graph or provider calls are introduced.
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
- Workspace and tenant isolation: PASS. Workspace membership remains first, and tenant/family omission happens before counts are exposed.
- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion.
- Destructive action discipline: PASS by non-use. No new destructive or risky actions are introduced.
- Global search: PASS. No new resource or search result is added.
- OperationRun / Ops-UX: PASS by non-use. No new run start or completion behavior exists.
- Data minimization: PASS. Default-visible content stays limited to family summaries, lane scope, and next action.
- Test governance: PASS. Proof remains in focused `Unit` and `Feature` lanes.
- Proportionality / no premature abstraction: PASS. The design extends an existing page and builder instead of introducing a new shell or engine.
- Persisted truth: PASS. No new table, artifact, or cached projection is introduced.
- Behavioral state: PASS. Any additional family key remains derived page state only.
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing page and navigation patterns are extended rather than bypassed.
- Provider boundary: PASS. No provider/platform seam widens.
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
**Gate evaluation**: PASS.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for section assembly and convergence routing, `Feature` for page visibility, family omission, and navigation continuity
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves derived-family assembly cheaply; feature coverage proves route access, family omission, tenant-prefilter continuity, and duplicate-truth prevention on existing pages
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse existing workspace, tenant, finding, exception, and review fixtures without widening into browser or heavy-governance families
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because return context and tenant-filter continuity are part of the contract
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the governance home stays read-only, and confirm specialist surfaces preserve lane-specific truth without duplicating the workspace summary
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
- **Review-stop questions**: lane fit, hidden fixture growth, accidental new shell, accidental new mutation lane, hidden leakage through counts
- **Escalation path**: `document-in-feature` for contained navigation-context notes; `reject-or-split` for any new shell or workflow-engine drift
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Test-governance outcome**: keep
- **Why no dedicated follow-up spec is needed**: the bounded convergence work remains feature-local unless future work demands a broader dashboard or portfolio action-center spec
## Rollout & Risk Controls
- Keep the governance inbox as the only primary start surface touched by this slice.
- Keep all specialist mutations on their existing pages.
- Do not widen the exception or review lane into new workflow state.
- Prefer extending the current section builder and navigation helper over adding a new orchestrator.
- Treat any attempt to add a second workspace summary banner on the specialist pages as out-of-scope drift.
## Project Structure
### Documentation (this feature)
```text
specs/257-governance-decision-convergence/
├── checklists/
│ └── requirements.md
├── spec.md
├── plan.md
└── tasks.md
```
This preparation package intentionally stays on the core artifacts plus the review checklist. The repo truth is already known, the slice adds no new persistence or external contract, and no extra research/data-model/contracts package is required to make the implementation bounded.
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/Pages/
│ │ ├── Findings/
│ │ │ ├── MyFindingsInbox.php
│ │ │ └── FindingsIntakeQueue.php
│ │ ├── Governance/
│ │ │ └── GovernanceInbox.php
│ │ ├── Monitoring/
│ │ │ └── FindingExceptionsQueue.php
│ │ └── Reviews/
│ │ └── CustomerReviewWorkspace.php
│ └── Support/
│ ├── GovernanceInbox/
│ │ └── GovernanceInboxSectionBuilder.php
│ ├── Navigation/
│ │ └── CanonicalNavigationContext.php
│ └── OperateHub/
│ └── OperateHubShell.php
└── resources/views/filament/pages/
├── findings/
│ ├── my-findings-inbox.blade.php
│ └── findings-intake-queue.blade.php
├── governance/
│ └── governance-inbox.blade.php
├── monitoring/
│ └── finding-exceptions-queue.blade.php
└── reviews/
└── customer-review-workspace.blade.php
```
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One additional derived family in `GovernanceInboxSectionBuilder` | the exception lane still sits outside the canonical decision home | leaving exceptions on a standalone specialist page keeps the current fragmented start state |
| One bounded convergence contract for arrival and return context | specialist pages need a truthful way back to the same governance scope | page-local ad hoc back links would drift across surfaces and duplicate navigation logic |
## Proportionality Review
- **Current operator problem**: operators still have to decide between several repo-real specialist surfaces before they can begin work.
- **Existing structure is insufficient because**: the current governance home does not yet own all high-signal lanes and the specialist pages do not clearly behave as secondary contexts.
- **Narrowest correct implementation**: extend the existing governance inbox and navigation continuity instead of adding a new shell or persisted workflow engine.
- **Ownership cost created**: maintain one more derived family, one bounded convergence helper, and focused tests.
- **Alternative intentionally rejected**: a new action-center page or persisted cross-family work queue was rejected as unnecessary structure for current-release truth.
- **Release truth**: current-release workflow compression.
## Implementation Strategy
### Suggested MVP Scope
MVP = **User Story 1 + User Story 2 together**. The convergence slice only becomes meaningful once the governance home shows the missing lanes and the specialist surfaces preserve truthful return context.
### Incremental Delivery
1. Extend the governance inbox family assembly and page rendering.
2. Add convergence-aware arrival and return semantics on the specialist pages.
3. Tighten duplicate-truth prevention and calm secondary-context copy.
4. Finish with focused validation and formatting.
### Team Strategy
1. Settle the governance inbox family extension and navigation-context contract first.
2. Parallelize unit coverage for builder behavior and feature coverage for navigation continuity.
3. Serialize merges around the shared governance inbox and specialist page views so the decision-home language stays coherent.

View File

@ -0,0 +1,320 @@
# Feature Specification: Governance Decision Surface Convergence v1
**Feature Branch**: `257-governance-decision-convergence`
**Created**: 2026-04-29
**Status**: Ready for implementation
**Input**: User description: "Prepare the roadmap-fit Decision-Based Operating Foundations slice as Governance Decision Surface Convergence: use the existing governance inbox as the canonical workspace decision home, converge the still-missing exception and customer-review decision lanes, and keep the work read-only, repo-grounded, and free of new workflow state."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has multiple repo-real decision surfaces such as `MyFindingsInbox`, `FindingsIntakeQueue`, `GovernanceInbox`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`, but operators still have to decide where to start by hopping between specialist pages.
- **Today's failure**: The product has a decision-first foundation, but it still lacks one clearly dominant workspace decision home that converges the open exception lane and the customer-review follow-up lane into the same calm operating model.
- **User-visible improvement**: An authorized workspace operator lands on one canonical governance home, sees the remaining high-signal lanes in one place, and can open a specialized surface with preserved context and a truthful return path instead of reconstructing the workflow manually.
- **Smallest enterprise-capable version**: Reuse the existing `/admin/governance/inbox` page as the canonical decision home, extend it with a derived `finding_exceptions` family from existing queue truth, make customer-review follow-up handoff explicit through existing review workspace surfaces, and align `My Findings`, `Findings intake`, `Finding exceptions`, and `Customer Review Workspace` behind shared arrival and return context. No new page shell, no new persistence, and no new mutation lane ship in this slice.
- **Explicit non-goals**: No new portal or dashboard shell, no new persisted inbox item or task state, no new assign/claim/approve/review mutations on the convergence surface, no cross-tenant compare or promotion work, no customer-facing portfolio board, no AI prioritization, and no generic workflow framework.
- **Permanent complexity imported**: One bounded family extension inside the existing governance inbox assembly path, one small convergence contract for arrival and return context across existing pages, and focused unit plus feature coverage.
- **Why now**: `docs/product/roadmap.md` still has an open `Decision-Based Operating Foundations` lane, while `docs/product/implementation-ledger.md` identifies decision-surface fragmentation as the highest unspecced operator workflow gap after already-specced compare, commercial, and cleanup packages are removed from the queue.
- **Why not local**: Extending only `FindingExceptionsQueue` or only `CustomerReviewWorkspace` would keep the current start-state ambiguity intact and would not establish a single truthful operator entry point.
- **Approval class**: Workflow Compression
- **Red flags triggered**: One multi-surface convergence flag and one derived-family-extension flag. Defense: the slice extends existing pages and builders, introduces no new persistence, and keeps all workflow state on the existing source surfaces.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- existing canonical workspace route `/admin/governance/inbox`
- existing `/admin/findings/my-work`
- existing `/admin/findings/intake`
- existing `/admin/finding-exceptions/queue`
- existing `/admin/reviews/workspace`
- existing tenant-scoped finding and review detail routes as drill-through targets only
- **Data Ownership**:
- `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only persisted truth for their respective families
- the convergence layer remains derived from the existing `GovernanceInbox` page and supporting builders; it introduces no new inbox-item table, cache, mirror entity, or workflow state
- no new review, exception, or decision summary persistence is introduced
- **RBAC**:
- workspace membership remains the first boundary for the canonical decision home and all converged launches
- non-members and explicit out-of-scope tenant filters remain `404` deny-as-not-found boundaries
- in-scope members who can access none of the converged families receive `403`, not a silent empty shell
- findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW` and keep claim or assignment semantics on their existing pages, including `Capabilities::TENANT_FINDINGS_ASSIGN`
- the exception lane reuses the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE`; this slice must not introduce a second exception capability family
- customer-review follow-up and review-pack handoff continue to reuse existing review and pack visibility checks; this slice must not introduce a new review-workspace capability family
- the convergence surface stays read-only; all mutations remain enforced on their existing source pages and actions
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped or tenant-prefiltered specialist page, the governance inbox prefilters to that tenant and, when relevant, to the originating family. Clearing filters returns to the workspace-wide decision home instead of preserving a local specialist scope forever.
- **Explicit entitlement checks preventing cross-tenant leakage**: Broad workspace listings silently omit inaccessible tenants and hidden families from counts, labels, and previews. Explicit tenant or record targets outside visible scope resolve as not found and do not leak family counts or presence hints.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, decision-home section summaries, action links, queue empty states, back-link continuity, and badge or status reuse
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, `CustomerReviewWorkspace`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing action-surface declarations on those pages
- **Existing pattern(s) to extend**: the existing governance inbox route and section builder, tenant-prefilter state handling, canonical navigation context, calm empty-state copy on specialist pages, and existing read-first decision surfaces
- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, `ActionSurfaceDeclaration`, and existing source-page capability guards
- **Why the existing shared path is sufficient or insufficient**: the existing source pages already own the truth and the current governance inbox already owns the canonical route, but the current convergence is insufficient because finding exceptions and explicit customer-review decision continuity still sit outside the calm default operating model
- **Allowed deviation and why**: none. The slice should extend the existing governance inbox and source pages instead of introducing a second convergence shell or a generic task framework.
- **Consistency impact**: `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, `Open customer review workspace`, and `Back to governance inbox` language must stay consistent across section copy, specialist page affordances, and empty-state recovery actions.
- **Review focus**: reviewers must block any implementation that creates a new standalone convergence page, adds local specialist mutations to the governance home, or duplicates specialist proof content on the decision surface.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` start or completion behavior is introduced
- **Shared OperationRun UX contract/layer reused**: existing deep-link-only behavior on the already-present stale-operations family remains unchanged
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: the governance home still decides whether the existing stale-operations section is shown, but this spec does not widen or redefine that contract
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no new provider or platform-core boundary is widened. This slice only converges existing operator-facing decision surfaces.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Governance inbox page | yes | Native Filament page plus existing section builder | decision-home summaries, navigation entry points, badge reuse | page, URL-query, derived family state | no | Existing canonical route remains authoritative; no new shell is added |
| Findings and exception specialist queues (`MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`) | yes | Native Filament pages | arrival context, return continuity, calm secondary-context copy | page, URL-query, table/session filter restore | no | Remain specialized secondary-context surfaces; no workflow ownership moves here |
| Customer review workspace | yes | Native Filament page | review-follow-up handoff, return continuity, calm read-only context | page, URL-query, table/session filter restore | no | Remains the review-consumption surface, not a second governance home |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Governance inbox page | Primary Decision Surface | Operator decides which lane to open next across findings, exceptions, stale operations, alert failures, and review follow-up | visible family counts, top blockers, tenant scope, and one dominant next action per section | specialist queue details, review detail, alert detail, and finding detail after explicit open | Primary because it becomes the single workspace start surface instead of one of several competing starts | Aligns the product with the roadmap's decision-first operating direction | Reduces page hopping before the first action |
| Findings and exception specialist queues | Secondary Context | Operator acts inside the chosen lane after the first decision is already made | lane-specific list rows, current tenant scope, and one existing safe next action | record detail, due context, approval context, and deeper diagnostics on existing detail surfaces | Secondary because the lane is chosen at the governance home, not discovered here first | Keeps specialist work inside the existing pages without making them compete as starts | Removes the need to re-evaluate the whole workspace after every lane jump |
| Customer review workspace | Secondary Context | Operator verifies the latest customer-safe review state after a governance or review-follow-up cue | latest published review outcome, pack availability, and read-only summary | full review detail and pack download after explicit open | Secondary because it remains a read-only review-consumption surface entered from a governance cue | Preserves the customer-safe review workflow while fitting into the same decision hierarchy | Prevents the review workspace from becoming a competing attention home |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Governance inbox page | operator-MSP | family summary, tenant scope, top blockers, and section CTA | diagnostics stay on the specialist pages | raw payloads and debug detail stay on existing source pages only | `Open attention lane` | raw detail is never rendered on the decision home | the page states the current blocker once and sends the operator to the owning surface for proof |
| Findings and exception specialist queues | operator-MSP | lane-specific queue rows, due cues, status, and existing next action | specialist diagnostics and record detail remain available on existing routes | raw/support detail stays behind existing record detail affordances | existing lane-owned action only | any broad workspace summary stays off the specialist pages | specialist pages do not restate the workspace decision-home summary |
| Customer review workspace | operator-MSP, customer-read-only | latest customer-safe review status, pack availability, and read-only summary | deeper review diagnostics stay on review detail | raw JSON, provider payloads, and internal support evidence remain hidden or gated on existing detail/download paths | `Open latest review` | support-only raw evidence stays off the workspace surface | review workspace states review truth once and relies on review detail for proof |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the correct existing lane | explicit section CTA or preview-entry CTA | forbidden | section footer links and preview-entry links only | none | `/admin/governance/inbox` | existing specialist routes only | active workspace, optional tenant and family filter | Governance inbox | which lane needs attention now | none |
| Findings and exception specialist queues | List / Table / Read-only decision queue | Specialist queue | Open the owning record or use the existing inline safe shortcut | row click and existing specialist inspect path | required | existing lane-owned actions only | existing grouped destructive or risky actions remain on owning detail surfaces | `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/finding-exceptions/queue` | existing finding or exception detail routes | tenant filter, queue view, queue-specific states | Findings / Finding exceptions | lane-specific actionable truth only | none |
| Customer review workspace | List / Table / Read-only workspace report | Read-only review consumption | Open the latest published review | clickable row to latest review or explicit download/open action | required | existing read-only actions only | none | `/admin/reviews/workspace` | existing tenant review detail route | tenant filter and review availability | Customer review workspace | latest published review state and pack availability | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | Workspace operator / MSP operator | Decide which governance lane to open next | Workspace decision hub | What needs attention now across my visible governance lanes? | family counts, top blockers, tenant scope, and section CTA | diagnostics remain on specialist pages | lane urgency, lane ownership, tenant scope | none | Open lane | none |
| Findings and exception specialist queues | Workspace operator / MSP operator | Work inside a chosen findings or exception lane | Specialist queue | What in this chosen lane needs action first? | queue rows, due or review cues, owner/assignee context, and current filter state | full record detail and deep diagnostics remain on existing detail routes | workflow status, due/overdue state, review or approval state | existing lane-owned mutations only | inspect record or existing lane action | existing queue-owned risky actions only |
| Customer review workspace | Workspace operator / readonly-capable tenant actor | Consume the latest review truth after a review-follow-up cue | Read-only review workspace | What is the latest published review state for this tenant? | latest review outcome, pack availability, and read-only summary | full review detail and pack detail after explicit open | review availability, review freshness, pack availability | none | Open latest review | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded convergence contract inside the existing governance inbox assembly and navigation-context seams
- **New enum/state/reason family?**: no new persisted family; any added family key remains derived and page-scoped
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: operators still start from multiple repo-real specialist surfaces even though the repo already has enough decision surfaces to support one calmer governance home
- **Existing structure is insufficient because**: the current governance inbox does not yet own all remaining high-signal lanes and the specialist pages do not clearly behave as secondary contexts
- **Narrowest correct implementation**: extend the existing governance inbox and current specialist pages with one more derived family and shared arrival/return semantics instead of creating a new shell or workflow engine
- **Ownership cost**: maintain one more derived section and a small navigation-context convergence layer plus focused tests
- **Alternative intentionally rejected**: a new global action center, persisted inbox-item table, and mutation-capable workflow engine were rejected as premature and structurally heavier than the current release truth requires
- **Release truth**: current-release workflow compression, not future-release platform speculation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical extension of the existing governance inbox is preferred over adding a parallel decision surface.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: unit coverage proves section assembly, family ordering, and convergence routing without Filament boot cost; focused feature coverage proves visibility, tenant-filter continuity, return context, and calm secondary-surface behavior on the existing pages
- **New or expanded test families**: focused `Unit/Support/GovernanceInbox` coverage plus focused `Feature/Governance`, `Feature/Monitoring`, `Feature/Reviews`, and `Feature/Findings` convergence coverage
- **Fixture / helper cost impact**: moderate; tests need workspace membership, visible and hidden tenants, findings, exceptions, review-follow-up states, and review-workspace fixtures, but should reuse existing factories and avoid browser or heavy-governance setup
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: special coverage is required for arrival/return context and duplicate-truth prevention across the specialist pages
- **Reviewer handoff**: reviewers must confirm that the governance inbox remains the single start surface, counts omit inaccessible families, specialist pages keep one dominant action, and no new mutation lane or persistence appears
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
## Scope Boundaries
### In Scope
- reuse the existing `GovernanceInbox` page as the canonical workspace decision home
- extend the governance inbox with a derived finding-exceptions family sourced from existing queue truth
- make customer-review follow-up and customer-review-workspace handoff explicit within the same decision hierarchy
- preserve tenant and family arrival context plus truthful return links between the governance home and `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`
- keep specialist pages as calm secondary-context surfaces with no duplicate workspace-level blocker summary
### Non-Goals
- creating a new global action-center page or dashboard shell
- replacing the existing specialist pages or moving their mutations to the governance home
- adding a new persisted inbox item, queue state, or workflow engine
- changing existing finding, exception, or review lifecycle semantics
- cross-tenant compare, promotion, or portfolio execution work
- customer-facing portfolio boards or AI-driven prioritization
## Assumptions
- the existing `GovernanceInboxSectionBuilder` can accept one more derived family without turning into a generic task engine
- current `CanonicalNavigationContext` and tenant-prefilter handling are sufficient to preserve truthful return paths between the decision home and specialist pages
- `CustomerReviewWorkspace` remains the correct read-only destination for customer-safe review consumption when a published review detail is not the better direct target
## Risks
- implementation could overreach and turn the governance home into a new task engine instead of a routing surface
- the finding-exceptions family could leak hidden tenant hints if capability and tenant scoping are not applied before counts and previews are derived
- specialist-page convergence could accidentally duplicate blocker language instead of keeping the decision summary on the governance home only
## Follow-up Candidates
- wider dashboard-entry convergence once the governance home proves adoption
- portfolio-level decision convergence with cross-tenant compare after Spec 043 implementation
- any mutation consolidation only after the read-only convergence hierarchy is proven and remains bounded
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Use one canonical governance home (Priority: P1)
As a workspace operator, I want one governance home that includes the still-missing exception and review-consumption lanes so I can decide where to work next without choosing between multiple start pages first.
**Why this priority**: This is the smallest slice that completes the roadmap's decision-first operating direction without inventing new workflow state.
**Independent Test**: Seed visible findings, finding exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and section CTAs.
**Acceptance Scenarios**:
1. **Given** the actor can see findings, finding exceptions, and review follow-up for the current workspace, **When** they open the governance inbox, **Then** the page shows those lanes in one canonical surface with one dominant action per section.
2. **Given** the actor cannot see finding exceptions, **When** they open the governance inbox, **Then** the exception lane does not appear and no count or empty-state hint implies hidden work exists.
3. **Given** the actor applies a tenant-prefilter that hides all current rows, **When** they open the governance inbox, **Then** the page explains that the tenant filter is hiding other visible attention instead of falsely implying the whole workspace is calm.
---
### User Story 2 - Move into a specialist lane and back without losing context (Priority: P1)
As a workspace operator, I want to open a specialist queue or review workspace from the governance home and come back with the same tenant and family context so the governance home becomes my operating anchor instead of a one-off report.
**Why this priority**: Convergence does not help if every lane jump loses the original decision context.
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
**Acceptance Scenarios**:
1. **Given** the actor opens `My Findings` or `Findings intake` from the governance inbox, **When** the specialist queue loads, **Then** the existing queue keeps its specialist semantics while exposing a truthful return path to the governance inbox.
2. **Given** the actor opens `Finding exceptions` from the governance inbox, **When** the queue loads, **Then** the queue preserves the arrival tenant context and does not become a competing workspace start surface.
3. **Given** the actor opens `Customer Review Workspace` from a review-follow-up cue, **When** they inspect the review lane, **Then** the page stays read-only and preserves the governance-home return path.
---
### User Story 3 - Keep specialist surfaces calm and secondary (Priority: P2)
As a workspace operator, I want specialist queues and the customer review workspace to keep their own lane truth without re-explaining the whole workspace blocker summary so each page stays focused on the action I already chose.
**Why this priority**: Convergence should reduce attention load, not spread the same summary across more pages.
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps its lane-specific content while the workspace-level blocker summary remains on the governance home.
**Acceptance Scenarios**:
1. **Given** the actor opens a specialist queue from the governance home, **When** the specialist page renders, **Then** it shows only lane-specific actionable truth and not a duplicated workspace summary banner.
2. **Given** the actor opens the customer review workspace from a review-follow-up cue, **When** the page renders, **Then** it shows customer-safe review truth and not a second governance-home summary card.
### Edge Cases
- the actor can access the governance inbox but none of the converged specialist families
- the requested tenant filter is outside the actor's visible scope
- the same tenant has findings, exceptions, and review follow-up at once, but the governance home must still avoid duplicating the same blocker explanation across sections
- review follow-up exists for a tenant without a currently published review, requiring the fallback customer-review-workspace destination
- the selected tenant is calm for exceptions but not for other families, so the empty-state message must be truthful about what the filter is hiding
## Requirements *(mandatory)*
### Functional Requirements
- **FR-257-001 Canonical decision home**: The system MUST treat the existing `/admin/governance/inbox` page as the canonical workspace decision home for operator-facing governance attention.
- **FR-257-002 Exception-family convergence**: The governance inbox MUST derive a `finding_exceptions` family from existing `FindingExceptionsQueue` truth and render it without adding a new persisted inbox item or queue-state layer.
- **FR-257-003 Review-consumption handoff**: Review-follow-up cues on the governance inbox MUST route into the existing review-consumption surfaces, using the latest published review when available and the existing customer review workspace when it is the truthful fallback.
- **FR-257-004 Arrival and return continuity**: Launching `My Findings`, `Findings intake`, `Finding exceptions`, or `Customer Review Workspace` from the governance inbox MUST preserve truthful arrival and return context for the current tenant and family scope.
- **FR-257-005 Secondary-surface discipline**: Specialist queues and the customer review workspace MUST remain secondary-context surfaces and MUST NOT become competing workspace start surfaces through duplicated workspace summary banners or second primary CTAs.
- **FR-257-006 Visibility and omission semantics**: Family counts, section previews, and empty states MUST be derived only from tenants and families the actor can already see through existing capability and entitlement checks.
- **FR-257-007 Authorization semantics**: Non-members and out-of-scope tenant targets MUST resolve as `404`, while in-scope members who lack visibility to every converged family MUST receive `403`.
- **FR-257-008 No new workflow truth**: The slice MUST NOT add a new inbox-item table, a persisted convergence state, or a new cross-family mutation contract.
- **FR-257-009 Source-surface ownership**: Claim, assignment, approval, review, and pack-download behaviors MUST remain on their existing source surfaces and continue to enforce their existing capabilities there.
- **FR-257-010 Decision-first disclosure**: The governance inbox MUST show summary and next-action content only; raw payloads, review evidence, and specialist diagnostics MUST remain on the owning specialist or detail surfaces.
- **FR-257-011 Duplicate-truth prevention**: The governance inbox and the specialist surfaces MUST NOT restate the same workspace-level blocker or next-action summary as equal-priority content.
- **FR-257-012 Read-only review integrity**: The customer review workspace remains read-only in this slice and MUST NOT gain operator-only mutation controls through convergence work.
### Non-Functional Requirements
- **NFR-257-001**: The convergence layer remains DB-only and derived from existing persisted truth; it MUST NOT add Graph calls, remote calls, queues, or `OperationRun` creation.
- **NFR-257-002**: The slice MUST reuse existing Filament and shared UI primitives before any local UI framework or semantic layer is introduced.
- **NFR-257-003**: The feature MUST stay within focused `Unit` and `Feature` lanes only; browser or heavy-governance coverage is out of scope unless implementation proves a specific need.
### UX Requirements
- **UXR-257-001**: The governance inbox remains the one dominant start surface for the converged lanes.
- **UXR-257-002**: Each affected surface has exactly one dominant next action visible by default.
- **UXR-257-003**: Specialist surfaces keep lane-specific truth only and rely on explicit return links for workspace context.
### RBAC / Security Requirements
- **RBR-257-001**: The slice MUST reuse existing capability registries and MUST NOT introduce raw capability strings or role-name checks.
- **RBR-257-002**: Tenant-filter and family-filter state MUST NOT leak inaccessible tenant or family hints through counts, labels, or empty-state copy.
### Auditability / Observability Requirements
- **AOR-257-001**: The slice MUST NOT create a new page-view audit stream; existing audit ownership remains on the existing source-surface mutations and downloads.
- **AOR-257-002**: Any convergence-specific navigation or UI state remains derived and inspectable through tests rather than new runtime logging.
### Data / Truth-Source Requirements
- **DTR-257-001**: `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only source truth inputs for the decision home.
- **DTR-257-002**: Any added convergence family key remains derived page state, not persisted domain truth.
## Out of Scope
- new persistence or workflow-state layers
- new operator mutations on the governance home
- cross-tenant compare or promotion work
- customer-facing portfolio boards or customer portal changes
- AI prioritization or recommendation logic
## Acceptance Criteria
- the selected operator can open one canonical governance home that includes the missing exception lane and truthful review-consumption handoff without seeing a second competing start surface
- specialist pages preserve truthful arrival and return context when opened from the governance home
- hidden families and inaccessible tenants do not leak through counts, labels, or empty-state hints
- the customer review workspace remains read-only and customer-safe while participating in the same decision hierarchy
- no new persistence, workflow state, queue, or runtime mutation surface is introduced
## Success Criteria
- operators can explain one default start surface for governance work in the workspace
- the specialist pages feel like chosen lanes, not competing homes
- implementation can stay bounded to existing page and builder seams with no new framework layer
## Open Questions
- none

View File

@ -0,0 +1,189 @@
---
description: "Task list for Governance Decision Surface Convergence v1"
---
# Tasks: Governance Decision Surface Convergence v1
**Input**: Design documents from `specs/257-governance-decision-convergence/`
**Prerequisites**: `specs/257-governance-decision-convergence/plan.md` (required), `specs/257-governance-decision-convergence/spec.md` (required)
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage for this read-only convergence slice.
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Existing stale-operation links remain unchanged.
**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets return `404`; in-scope members with no visible family return `403`. Findings lanes reuse `Capabilities::TENANT_FINDINGS_VIEW`, existing inline safe actions keep their current capability checks such as `Capabilities::TENANT_FINDINGS_ASSIGN`, the exception lane reuses `Capabilities::FINDING_EXCEPTION_APPROVE`, and review handoff reuses existing review and pack visibility checks.
**Shared Pattern Reuse**: Reuse `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the existing specialist page action-surface contracts. No new shell, task engine, or persistence layer is allowed.
**Organization**: Tasks are grouped by user story so the governance-home extension, navigation convergence, and calm secondary-context rules remain independently testable after the shared groundwork is complete.
## Test Governance Checklist
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceInbox/`, `apps/platform/tests/Feature/Governance/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Monitoring/`, and `apps/platform/tests/Feature/Reviews/` families only.
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or generic workflow fixtures.
- [x] Planned validation commands cover governance-home assembly, authorization, and arrival/return continuity without widening scope.
- [x] The declared surface test profile remains `global-context-shell` because arrival context and tenant-filter continuity are part of the contract.
- [x] Any broader action-center, dashboard-entry, or cross-tenant follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden implementation growth.
- [x] Test-governance outcome resolves as `keep` for this feature and does not widen the work into a heavier family.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded convergence slice, the existing governance-home seams, and the reviewer stop conditions before implementation begins.
- [x] T001 Review the bounded convergence slice in `specs/257-governance-decision-convergence/spec.md` and `specs/257-governance-decision-convergence/plan.md` together with `docs/product/roadmap.md` and `docs/product/implementation-ledger.md`.
- [x] T002 [P] Confirm the current governance-home families and summary seams in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
- [x] T003 [P] Confirm the specialist-page arrival, return, and filter-state seams in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Extend the shared governance-home and navigation seams that every user story depends on.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T004 [P] Define or extend the bounded family-aware arrival and return contract inside `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and any minimal supporting helper under `apps/platform/app/Support/GovernanceInbox/` without creating new persistence or a generic workflow framework.
- [x] T005 [P] Tighten family omission and access evaluation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` so inaccessible tenants and families disappear before counts are derived and in-scope no-family access resolves as `403`.
- [x] T006 Implement the derived `finding_exceptions` family in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` using existing `FindingExceptionsQueue` truth, current queue semantics, and existing capability rules.
- [x] T007 Implement truthful review-consumption handoff logic in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` so review-follow-up entries prefer existing latest review detail and fall back to `CustomerReviewWorkspace` only when that is the honest destination.
- [x] T008 [P] Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to keep one dominant CTA per section and avoid duplicate workspace-summary cards as the new family is added.
**Checkpoint**: The governance home can derive the new family, family counts stay capability-safe, and navigation context rules are settled before story-specific work begins.
---
## Phase 3: User Story 1 - Use One Canonical Governance Home (Priority: P1)
**Goal**: Give the operator one governance home that includes the missing exception and review-consumption lanes without creating a new shell.
**Independent Test**: Seed visible findings, exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and one dominant CTA per section.
### Tests for User Story 1
- [x] T009 [P] [US1] Extend `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` to cover exception-family inclusion, family ordering, review-workspace fallback, and omission semantics for hidden tenants or families.
- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` to cover the visible exception lane, review-consumption handoff summary, tenant-filter empty-state truth, and one dominant CTA per section.
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` to cover `404` vs `403` behavior when workspace access exists but all converged family visibility is removed.
### Implementation for User Story 1
- [x] T012 [US1] Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the new convergence lane and family-aware summary or empty-state copy.
- [x] T013 [US1] Align governance-home copy in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to the stable vocabulary `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, and `Open customer review workspace`.
**Checkpoint**: User Story 1 is independently functional when the governance inbox truthfully shows the missing lane and routes to the existing specialist destinations.
---
## Phase 4: User Story 2 - Move Into A Specialist Lane And Back (Priority: P1)
**Goal**: Preserve tenant and family context when the operator opens a specialist page from the governance home and returns.
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
### Tests for User Story 2
- [x] T014 [P] [US2] Add or extend `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` for tenant and family arrival/return continuity across governance-home launches.
- [x] T015 [P] [US2] Add or extend `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` for governance-home arrival, preserved tenant context, and truthful `Back to governance inbox` continuity.
- [x] T016 [P] [US2] Add or extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for governance-home arrival and return continuity on review-follow-up launches, preferred latest-published-review destination when available, fallback to `CustomerReviewWorkspace` when not, preserved read-only state, and the absence of operator-only mutation controls.
- [x] T017 [P] [US2] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php` and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php` for governance-home launch and return continuity on the findings specialist pages.
### Implementation for User Story 2
- [x] T018 [US2] Wire governance-home arrival and return context through `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`.
- [x] T019 [US2] Expose truthful return affordances without adding a second primary CTA. Repo truth: these native Filament pages expose the affordance through page header actions in `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`; no specialist Blade edits were required.
**Checkpoint**: User Story 2 is independently functional when the operator can move between the governance home and the specialist pages without losing truthful context.
---
## Phase 5: User Story 3 - Keep Specialist Surfaces Calm And Secondary (Priority: P2)
**Goal**: Ensure the specialist pages stay focused on lane-specific truth and do not duplicate the workspace-level summary once convergence context exists.
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps lane-specific content while the workspace-level blocker summary remains on the governance home only.
### Tests for User Story 3
- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to assert duplicate-truth prevention, one dominant default action on each specialist surface, and secondary-context copy when the pages are opened from the governance home.
### Implementation for User Story 3
- [x] T021 [US3] Keep lane-specific summaries focused and avoid duplicating workspace-level blocker text. Repo truth: page classes now add secondary return context while existing specialist Blade views stay lane-focused; regression tests assert the governance-home summary text is absent from secondary pages.
- [x] T022 [US3] Align action-surface declarations, header affordances, and empty-state recovery actions across `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so the canonical start surface remains obvious.
**Checkpoint**: User Story 3 is independently functional when specialist surfaces remain lane-specific secondary contexts instead of competing starts.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`.
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`.
- [x] T025 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
- [x] T026 [P] Confirm the slice introduced no new asset registration, no new globally searchable resource, and no new mutation lane; record any bounded follow-up for broader dashboard-entry or portfolio action-center work in the active implementation notes.
- [x] T027 [P] Confirm the slice introduced no new Graph or remote calls, no queue or `OperationRun` start path, and no page-view audit or runtime logging stream; record any bounded follow-up if implementation uncovers a structural need outside this slice.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical governance-home truth.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the governance home is not a dead-end report.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because specialist pages must already participate in the convergence flow.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and establishes the new canonical decision-home behavior.
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the new home has truthful workflow continuity.
- **US3 (P2)**: independently testable after Phase 2 and refines the specialist pages once the convergence contract exists.
### Within Each User Story
- After the shared foundational contract work in Phase 2 is complete, write the listed Pest coverage first for each user story and make it fail for the intended gap.
- Land the shared builder and navigation contract before widening Blade or copy work.
- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story.
---
## Parallel Execution Examples
### User Story 1
- T009, T010, and T011 can run in parallel before runtime edits begin.
- After the family contract settles, T012 and T013 can proceed in parallel because rendering and copy alignment touch different seams.
### User Story 2
- T014, T015, T016, and T017 can run in parallel because they cover different destinations in the convergence flow.
- After T018 settles the shared navigation contract, T019 can follow to align the Blade affordances.
### User Story 3
- T020 can start before implementation finishes because it only captures the expected secondary-context behavior.
- T021 and T022 can proceed together once the shared convergence path is stable.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. The slice becomes product-meaningful only when the governance home shows the missing lanes and the specialist pages preserve truthful return context.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and US2 together.
3. Add US3 secondary-context tightening.
4. Finish with focused validation and formatting in Phase 6.
### Team Strategy
1. Settle the governance-home family extension and navigation-context contract first.
2. Parallelize unit and feature coverage inside each story before runtime edits widen.
3. Serialize merges around the governance inbox and specialist Blade views so the decision-home language stays coherent.