Compare commits

..

8 Commits

Author SHA1 Message Date
e1136ac6e9 Merge platform-dev into dev (automated) (#309)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Automatischer Commit und PR erstellt auf Anfrage.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #309
2026-04-30 14:41:01 +00:00
61feb48d8a chore(platform): merge platform-dev into dev (#308)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.

Refresh method in this update: merge from `origin/dev` into `platform-dev` on explicit user request.

This PR was created by agent on user request; do not merge automatically.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #308
2026-04-30 07:52:08 +00:00
905b595880 chore(sync): platform-dev → dev (#306)
Some checks failed
Main Confidence / confidence (push) Failing after 55s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
Automatisch erstellter PR: Synchronisiere `platform-dev` nach `dev`.

Enthält alle Änderungen, die aktuell in `platform-dev` vorhanden sind. Bitte Review und Merge gegen `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #306
2026-04-29 22:44:27 +00:00
7b394918ce chore(platform): merge platform-dev into dev (#302)
Some checks failed
Main Confidence / confidence (push) Failing after 1m48s
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m43s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.

This PR was created by agent on user request; do not merge automatically.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #302
2026-04-29 20:53:36 +00:00
4b36d2c64f Automated PR: platform-dev → dev (#300)
Some checks failed
Main Confidence / confidence (push) Failing after 1m0s
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m50s
Automated PR created by Copilot. Commit: 4b0dc2a62e

This PR merges branch `platform-dev` into `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #300
2026-04-29 13:01:43 +00:00
ab9c36f21e Automatische PR: platform-dev → dev (#299)
Some checks failed
Main Confidence / confidence (push) Failing after 59s
Automatisch erstellt: Merge `platform-dev` into `dev` (via MCP)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #299
2026-04-29 12:37:48 +00:00
54fb65a63a chore: promote platform-dev to dev (#297)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
This pull request promotes the current state of `platform-dev` to the main integration branch `dev`. It includes recent features, fixes, and architectural refinements validated on the platform development track.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #297
2026-04-29 07:50:16 +00:00
29ad8852ca merge: platform-dev into dev (#295)
Some checks failed
Main Confidence / confidence (push) Failing after 1m1s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- integrate the current `platform-dev` branch into `dev`
- bring the latest platform work from the integration branch into the main development branch
- include the recent findings lifecycle backfill removal slice together with the already accumulated `platform-dev` changes

## Scope
- source branch: `platform-dev`
- target branch: `dev`
- branch role: integration PR, not a single-feature PR

## Validation
- branch state reviewed before PR creation
- `platform-dev` is ahead of `dev` with the expected integration history
- this PR intentionally carries the accumulated `platform-dev` commits into `dev`

## Notes
- this is the correct merge direction for the current workflow, where feature branches land in `platform-dev` first and `platform-dev` is then merged into `dev`
- after merging, `platform-dev` can be recreated fresh from `dev` as usual

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #295
2026-04-28 22:11:20 +00:00
118 changed files with 10766 additions and 736 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

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

View File

@ -59,6 +59,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_DESK_ENABLED=false
SUPPORT_DESK_NAME="External support desk"
SUPPORT_DESK_CREATE_URL=
SUPPORT_DESK_API_TOKEN=
SUPPORT_DESK_TICKET_URL_TEMPLATE=
SUPPORT_DESK_TIMEOUT_SECONDS=5
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1

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

View File

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

View File

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

View File

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

@ -31,6 +31,7 @@
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
@ -49,6 +50,7 @@
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable;
@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label('Request support')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->record($this->run)
->slideOver()
->stickyModalHeader()
->modalHeading('Request support')
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
->modalSubmitActionLabel('Submit support request')
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_run_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('primary_context')
->label('Primary context')
->label(__('localization.dashboard.primary_context'))
->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(),
Placeholder::make('included_context')
->label('Included context')
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity')
->label('Severity')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label('Summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label('Reproduction notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label('Contact name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email')
->label('Contact email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveViewerActor()->email),
])
@ -312,9 +345,21 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make()
->title('Support request submitted')
->body('Reference '.$supportRequest->internal_reference)
->success()
->title(__('localization.dashboard.support_request_submitted'))
->body($this->supportRequestNotificationBody($supportRequest))
->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send();
});
@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
}
private function operationLatestSupportRequestHandoffSummary(): string
{
$user = $this->resolveViewerActor();
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
/**
* @param array<string, mixed> $bundle
*/

View File

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

@ -21,6 +21,7 @@
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -30,6 +31,7 @@
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View;
@ -108,6 +110,37 @@ private function requestSupportAction(): Action
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
@ -138,8 +171,20 @@ private function requestSupportAction(): Action
Notification::make()
->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference)
->success()
->body($this->supportRequestNotificationBody($supportRequest))
->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send();
});
@ -281,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
}
private function tenantLatestSupportRequestHandoffSummary(): string
{
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$user = $this->resolveDashboardActor();
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
@ -15,9 +16,11 @@
use App\Models\TenantOnboardingSession;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
@ -44,6 +47,7 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
@ -68,6 +72,7 @@
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -824,6 +829,27 @@ public static function table(Table $table): Table
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('compareTenants')
->label('Compare tenants')
->icon('heroicon-o-scale')
->color('gray')
->url(function (Tenant $record, mixed $livewire): string {
$triageState = $livewire instanceof Pages\ListTenants
? static::currentPortfolioTriageState($livewire)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
}
return static::crossTenantCompareOpenUrl($record, $triageState);
})
->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
@ -966,6 +992,34 @@ public static function table(Table $table): Table
])
->bulkActions([
BulkActionGroup::make([
Actions\BulkAction::make('compareSelected')
->label('Compare selected')
->icon('heroicon-o-scale')
->color('gray')
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->extraAttributes(fn (mixed $livewire): array => [
'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null',
'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire),
'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire),
'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }',
])
->action(function (Collection $records, mixed $livewire): void {
$disabledReason = static::crossTenantCompareBulkDisabledReason($records);
if ($disabledReason !== null) {
Notification::make()
->title($disabledReason)
->danger()
->send();
return;
}
if (method_exists($livewire, 'redirect')) {
$livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true);
}
}),
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
@ -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{
* 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(
array $backupPostures,
array $recoveryEvidence,

View File

@ -1871,8 +1871,11 @@ private function upsertFindings(
} else {
$this->observeFinding(
finding: $finding,
tenant: $tenant,
observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(),
severity: (string) $driftItem['severity'],
slaPolicy: $slaPolicy,
);
}
@ -1947,12 +1950,21 @@ private function upsertFindings(
];
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
private function observeFinding(
Finding $finding,
Tenant $tenant,
CarbonImmutable $observedAt,
int $currentOperationRunId,
string $severity,
FindingSlaPolicy $slaPolicy,
): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
$finding->last_seen_at = $observedAt;
}
@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}
/**

View File

@ -32,6 +32,20 @@ class SupportRequest extends Model
public const string SEVERITY_BLOCKING = 'blocking';
public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only';
public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket';
public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket';
public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked';
public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed';
protected $guarded = [];
/**
@ -65,6 +79,53 @@ public static function severityValues(): array
return array_keys(self::severityOptions());
}
/**
* @return array<string, string>
*/
public static function externalHandoffModeOptions(): array
{
return [
self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only',
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket',
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket',
];
}
/**
* @return list<string>
*/
public static function externalHandoffModeValues(): array
{
return array_keys(self::externalHandoffModeOptions());
}
public function hasExternalTicket(): bool
{
return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== '';
}
public function hasExternalHandoffFailure(): bool
{
return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== '';
}
public function externalHandoffOutcome(): string
{
if ($this->hasExternalHandoffFailure()) {
return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED;
}
if (! $this->hasExternalTicket()) {
return self::HANDOFF_OUTCOME_INTERNAL_ONLY;
}
return match ($this->external_handoff_mode) {
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED,
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED,
default => self::HANDOFF_OUTCOME_INTERNAL_ONLY,
};
}
/**
* @return list<string>
*/

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
@ -181,6 +182,7 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,
CrossTenantComparePage::class,
GovernanceInbox::class,
FindingsHygieneReport::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(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
@ -173,4 +210,87 @@ public function logSupportRequestCreated(
tenant: $tenant,
);
}
public function logSupportRequestExternalTicketCreated(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketCreated,
status: 'success',
summaryPrefix: 'External ticket created for support request ',
);
}
public function logSupportRequestExternalTicketLinked(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketLinked,
status: 'success',
summaryPrefix: 'External ticket linked for support request ',
);
}
public function logSupportRequestExternalHandoffFailed(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalHandoffFailed,
status: 'failed',
summaryPrefix: 'External handoff failed for support request ',
);
}
private function logSupportRequestExternalHandoff(
SupportRequest $supportRequest,
User|PlatformUser|null $actor,
AuditActionId $action,
string $status,
string $summaryPrefix,
): \App\Models\AuditLog {
$supportRequest->loadMissing(['tenant.workspace']);
$tenant = $supportRequest->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Support requests must belong to a tenant.');
}
$metadata = [
'internal_reference' => $supportRequest->internal_reference,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (string) $supportRequest->operation_run_id
: (string) $tenant->getKey(),
'external_handoff_mode' => $supportRequest->external_handoff_mode,
'external_ticket_reference' => $supportRequest->external_ticket_reference,
];
if ($supportRequest->external_handoff_failure_summary !== null) {
$metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary;
}
return $this->log(
workspace: $tenant->workspace,
action: $action,
context: $metadata,
actor: $actor,
status: $status,
resourceType: 'support_request',
resourceId: (string) $supportRequest->getKey(),
targetLabel: $supportRequest->internal_reference,
summary: $summaryPrefix.$supportRequest->internal_reference,
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
tenant: $tenant,
);
}
}

View File

@ -163,7 +163,7 @@ private function upsertFinding(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([
'severity' => $severity,
@ -253,7 +253,7 @@ private function handleGaAggregate(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
$existing->forceFill([
'severity' => Finding::SEVERITY_HIGH,
@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy
return $this->slaPolicy ?? app(FindingSlaPolicy::class);
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1;
return;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($timesSeen < 1) {
$finding->times_seen = 1;
$slaPolicy = $this->resolveSlaPolicy();
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}

View File

@ -140,7 +140,7 @@ private function handleMissingPermission(
->first();
if ($finding instanceof Finding) {
$this->observeFinding($finding, $observedAt);
$this->observeFinding($finding, $tenant, $observedAt, $severity);
$finding->forceFill([
'severity' => $severity,
@ -216,7 +216,7 @@ private function handleErrorPermission(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([
'severity' => $severity,
@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
return CarbonImmutable::now();
}
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
{
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1;
return;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($timesSeen < 1) {
$finding->times_seen = 1;
if ($finding->sla_days === null) {
$finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}

View File

@ -69,6 +69,7 @@ enum AuditActionId: string
case BaselineCompareStarted = 'baseline_compare.started';
case BaselineCompareCompleted = 'baseline_compare.completed';
case BaselineCompareFailed = 'baseline_compare.failed';
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
case BaselineAssignmentCreated = 'baseline_assignment.created';
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
@ -103,6 +104,9 @@ enum AuditActionId: string
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created';
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
@ -215,6 +219,7 @@ private static function labels(): array
self::BaselineCompareStarted->value => 'Baseline compare started',
self::BaselineCompareCompleted->value => 'Baseline compare completed',
self::BaselineCompareFailed->value => 'Baseline compare failed',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
@ -248,6 +253,9 @@ private static function labels(): array
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
@ -306,6 +314,7 @@ private static function summaries(): array
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::AlertDestinationCreated->value => 'Alert destination created',
self::AlertDestinationUpdated->value => 'Alert destination updated',
self::AlertDestinationDeleted->value => 'Alert destination deleted',
@ -338,6 +347,9 @@ private static function summaries(): array
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',

View File

@ -6,14 +6,15 @@
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
@ -21,14 +22,12 @@
use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder
@ -41,6 +40,7 @@
private const FAMILY_ORDER = [
'assigned_findings',
'intake_findings',
'finding_exceptions',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
@ -71,6 +71,7 @@ public function build(
array $visibleFindingTenants,
array $reviewTenants,
bool $canViewAlerts,
bool $canViewFindingExceptions = false,
?Tenant $selectedTenant = null,
?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null,
@ -113,6 +114,22 @@ public function build(
}
if ($authorizedTenantsById !== []) {
if ($canViewFindingExceptions) {
$findingExceptionsSection = $this->findingExceptionsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
$availableFamilies[] = [
'key' => $findingExceptionsSection['key'],
'label' => $findingExceptionsSection['label'],
'count' => $findingExceptionsSection['count'],
];
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
}
$operationsSection = $this->operationsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
@ -191,6 +208,59 @@ public function build(
];
}
/**
* @param array<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
* @return array<int, Tenant>
@ -477,28 +547,10 @@ private function reviewFollowUpSection(
'label' => 'Review follow-up',
'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up',
'dominant_action_label' => 'Open customer review workspace',
'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
: $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.'
@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te
});
}
/**
* @param array<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>
*/
@ -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
* @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
{
$total = $followUpCount + $changedCount;

View File

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

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,256 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\SupportRequest;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
final class ExternalSupportDeskHandoffService
{
private const int MAX_TIMEOUT_SECONDS = 5;
/**
* @return array{
* successful: bool,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* failure_summary: ?string
* }
*/
public function createTicket(SupportRequest $supportRequest): array
{
if (! $this->targetIsConfigured()) {
return $this->failed('External support desk target is not configured.');
}
try {
$response = Http::timeout($this->timeoutSeconds())
->acceptJson()
->asJson()
->withHeaders($this->headers())
->post($this->createUrl(), $this->payloadFor($supportRequest));
} catch (ConnectionException) {
return $this->failed('External support desk did not respond before the configured timeout.');
} catch (RequestException $exception) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').');
}
if (! $response->successful()) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').');
}
$responsePayload = $response->json();
$responsePayload = is_array($responsePayload) ? $responsePayload : [];
$reference = $this->normalizeReference(
data_get($responsePayload, 'ticket_reference')
?? data_get($responsePayload, 'external_ticket_reference')
?? data_get($responsePayload, 'reference')
?? data_get($responsePayload, 'key')
?? data_get($responsePayload, 'id'),
throwOnInvalid: false,
);
if ($reference === null) {
return $this->failed('External support desk did not return a ticket reference.');
}
$url = $this->normalizeUrl(
data_get($responsePayload, 'ticket_url')
?? data_get($responsePayload, 'external_ticket_url')
?? data_get($responsePayload, 'url')
?? data_get($responsePayload, 'web_url')
?? data_get($responsePayload, 'html_url'),
throwOnInvalid: false,
) ?? $this->urlFromTemplate($reference);
return [
'successful' => true,
'external_ticket_reference' => $reference,
'external_ticket_url' => $url,
'failure_summary' => null,
];
}
/**
* @return array{external_ticket_reference: string, external_ticket_url: ?string}
*/
public function normalizeLinkedTicket(mixed $reference, mixed $url): array
{
$normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true);
if ($normalizedReference === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
return [
'external_ticket_reference' => $normalizedReference,
'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference),
];
}
public function targetIsConfigured(): bool
{
return (bool) config('support_desk.target.enabled', false)
&& $this->createUrl() !== null;
}
public function targetName(): string
{
$name = config('support_desk.target.name', 'External support desk');
return is_string($name) && trim($name) !== ''
? trim($name)
: 'External support desk';
}
public function timeoutSeconds(): int
{
$configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS);
$seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS;
return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS));
}
/**
* @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string}
*/
private function failed(string $summary): array
{
return [
'successful' => false,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'failure_summary' => $this->boundedFailureSummary($summary),
];
}
private function createUrl(): ?string
{
return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false);
}
/**
* @return array<string, string>
*/
private function headers(): array
{
$headers = [];
$token = config('support_desk.target.api_token');
if (is_string($token) && trim($token) !== '') {
$headers['Authorization'] = 'Bearer '.trim($token);
}
return $headers;
}
/**
* @return array<string, mixed>
*/
private function payloadFor(SupportRequest $supportRequest): array
{
return [
'support_request' => [
'internal_reference' => $supportRequest->internal_reference,
'severity' => $supportRequest->severity,
'summary' => $supportRequest->summary,
'reproduction_notes' => $supportRequest->reproduction_notes,
'contact_name' => $supportRequest->contact_name,
'contact_email' => $supportRequest->contact_email,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? $supportRequest->operation_run_id
: $supportRequest->tenant_id,
'workspace_id' => $supportRequest->workspace_id,
'tenant_id' => $supportRequest->tenant_id,
'operation_run_id' => $supportRequest->operation_run_id,
],
'context_envelope' => $supportRequest->context_envelope,
];
}
private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$reference = trim((string) $value);
if ($reference === '') {
return null;
}
if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference format is invalid.',
]);
}
return null;
}
return $reference;
}
private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value)) {
return null;
}
$url = trim($value);
if ($url === '') {
return null;
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.',
]);
}
return null;
}
return $url;
}
private function urlFromTemplate(string $reference): ?string
{
$template = config('support_desk.target.ticket_url_template');
if (! is_string($template) || trim($template) === '') {
return null;
}
$url = str_replace(
['{reference}', '{ticket}'],
rawurlencode($reference),
trim($template),
);
return $this->normalizeUrl($url, throwOnInvalid: false);
}
private function boundedFailureSummary(string $summary): string
{
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
return mb_substr($summary, 0, 500);
}
}

View File

@ -20,6 +20,7 @@ public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
@ -95,7 +96,7 @@ private function submit(
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
$connection = SupportRequest::query()->getModel()->getConnection();
return $connection->transaction(function () use (
$supportRequest = $connection->transaction(function () use (
$actor,
$contactEmail,
$contactName,
@ -127,6 +128,181 @@ private function submit(
return $supportRequest;
});
return $this->finalizeExternalHandoff($supportRequest, $actor, $validated);
}
/**
* @param array{
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string
* } $validated
*/
private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest
{
$mode = $validated['external_handoff_mode'];
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
])->save();
return $supportRequest->refresh();
}
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => $linkedTicket['external_ticket_reference'],
'external_ticket_url' => $linkedTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor);
return $supportRequest;
}
$createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest);
if ($createdTicket['successful']) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => $createdTicket['external_ticket_reference'],
'external_ticket_url' => $createdTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor);
return $supportRequest;
}
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => $createdTicket['failure_summary'],
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor);
return $supportRequest;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array
{
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT)
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array
{
$run->loadMissing('tenant.workspace');
$tenant = $run->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $run->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->where('operation_run_id', (int) $run->getKey())
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }
*/
private function summaryFor(SupportRequest $supportRequest): array
{
return [
'internal_reference' => (string) $supportRequest->internal_reference,
'primary_context_type' => (string) $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null)
: (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null),
'submitted_at' => $supportRequest->created_at?->toIso8601String(),
'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY),
'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference),
'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url),
'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary),
'has_external_link' => $supportRequest->hasExternalTicket(),
'has_failure' => $supportRequest->hasExternalHandoffFailure(),
];
}
/**
@ -137,10 +313,20 @@ private function submit(
* reproduction_notes: ?string,
* contact_name: ?string,
* contact_email: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* }
*/
private function validate(array $data): array
{
$requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null)
?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) {
$requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
}
$validated = validator(
[
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
@ -148,6 +334,9 @@ private function validate(array $data): array
'reproduction_notes' => $data['reproduction_notes'] ?? null,
'contact_name' => $data['contact_name'] ?? null,
'contact_email' => $data['contact_email'] ?? null,
'external_handoff_mode' => $requestedHandoffMode,
'external_ticket_reference' => $data['external_ticket_reference'] ?? null,
'external_ticket_url' => $data['external_ticket_url'] ?? null,
],
[
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
@ -155,6 +344,9 @@ private function validate(array $data): array
'reproduction_notes' => ['nullable', 'string'],
'contact_name' => ['nullable', 'string'],
'contact_email' => ['nullable', 'email'],
'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())],
'external_ticket_reference' => ['nullable', 'string', 'max:255'],
'external_ticket_url' => ['nullable', 'url', 'max:2048'],
],
)->validate();
@ -169,6 +361,27 @@ private function validate(array $data): array
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
$validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null);
$validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null);
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET
&& $validated['external_ticket_reference'] === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
}
if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$validated['external_ticket_reference'] = null;
$validated['external_ticket_url'] = null;
}
return $validated;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'target' => [
'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false),
'name' => env('SUPPORT_DESK_NAME', 'External support desk'),
'create_url' => env('SUPPORT_DESK_CREATE_URL'),
'api_token' => env('SUPPORT_DESK_API_TOKEN'),
'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'),
'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5),
],
];

View File

@ -51,6 +51,10 @@ public function definition(): array
],
'omissions' => [],
],
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
];
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\SupportRequest;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->string('external_handoff_mode')
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->after('context_envelope');
$table->string('external_ticket_reference')->nullable()->after('external_handoff_mode');
$table->text('external_ticket_url')->nullable()->after('external_ticket_reference');
$table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url');
});
}
public function down(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->dropColumn([
'external_handoff_mode',
'external_ticket_reference',
'external_ticket_url',
'external_handoff_failure_summary',
]);
});
}
};

View File

@ -80,14 +80,40 @@
'request_support' => 'Support anfragen',
'support_request_heading' => 'Support anfragen',
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
'submit_request' => 'Anfrage senden',
'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.',
'submit_request' => 'Supportanfrage senden',
'primary_context' => 'Primärer Kontext',
'included_context' => 'Enthaltener Kontext',
'latest_external_handoff' => 'Letzte externe Übergabe',
'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.',
'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.',
'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.',
'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure',
'external_handoff_mode' => 'Externe Übergabe',
'handoff_mode_internal_only' => 'Nur TenantPilot',
'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen',
'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen',
'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.',
'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.',
'handoff_mutation_scope' => 'Änderungsumfang',
'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.',
'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.',
'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.',
'external_ticket_reference' => 'Externe Ticketreferenz',
'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.',
'external_ticket_url' => 'Externe Ticket-URL',
'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.',
'severity' => 'Schweregrad',
'summary' => 'Zusammenfassung',
'reproduction_notes' => 'Reproduktionshinweise',
'contact_name' => 'Kontaktname',
'contact_email' => 'Kontakt-E-Mail',
'support_request_submitted' => 'Supportanfrage gesendet',
'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.',
'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.',
'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.',
'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure',
'open_external_ticket' => 'Externes Ticket öffnen',
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
'support_diagnostics' => 'Supportdiagnosen',
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',

View File

@ -80,14 +80,40 @@
'request_support' => 'Request support',
'support_request_heading' => 'Request support',
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
'submit_request' => 'Submit request',
'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.',
'submit_request' => 'Submit support request',
'primary_context' => 'Primary context',
'included_context' => 'Included context',
'latest_external_handoff' => 'Latest external handoff',
'latest_external_handoff_none' => 'No support request has been submitted for this context yet.',
'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.',
'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.',
'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure',
'external_handoff_mode' => 'External handoff',
'handoff_mode_internal_only' => 'TenantPilot only',
'handoff_mode_create_external_ticket' => 'Create external ticket',
'handoff_mode_link_existing_ticket' => 'Link existing ticket',
'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.',
'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.',
'handoff_mutation_scope' => 'Mutation scope',
'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.',
'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.',
'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.',
'external_ticket_reference' => 'External ticket reference',
'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.',
'external_ticket_url' => 'External ticket URL',
'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.',
'severity' => 'Severity',
'summary' => 'Summary',
'reproduction_notes' => 'Reproduction notes',
'contact_name' => 'Contact name',
'contact_email' => 'Contact email',
'support_request_submitted' => 'Support request submitted',
'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.',
'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.',
'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.',
'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure',
'open_external_ticket' => 'Open external ticket',
'open_support_diagnostics' => 'Open support diagnostics',
'support_diagnostics' => 'Support diagnostics',
'support_diagnostics_description' => 'Redacted tenant context from existing records.',

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>
<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>
</div>

View File

@ -528,6 +528,133 @@
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
});
it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $workspaceSafeExternalId,
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => $displayName],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
'display_name' => $displayName,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run1))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->sole();
$expectedSlaDays = (int) $finding->sla_days;
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($expectedSlaDays)->toBeGreaterThan(0)
->and($expectedDueAt)->not->toBeNull();
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding->refresh();
expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe($expectedSlaDays)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
\Carbon\CarbonImmutable::setTestNow();
});
it('does not create new finding identities when a new snapshot is captured', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');

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

@ -195,6 +195,54 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00');
});
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
[$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$generator->generate($tenant, buildPayload(
[gaRoleDef()],
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
'2026-02-24T10:00:00Z',
));
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->firstOrFail();
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($finding->sla_days)->toBe(3)
->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00');
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$result = $generator->generate($tenant, buildPayload(
[gaRoleDef()],
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
'2026-02-24T11:00:00Z',
));
$finding->refresh();
expect($result->created)->toBe(0)
->and($result->unchanged)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe(3)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
CarbonImmutable::setTestNow();
});
it('auto-resolves when assignment is removed', function (): void {
[$user, $tenant] = createMinimalUserWithTenant();

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\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
@ -46,6 +47,28 @@
->reopened()
->create();
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'subject_external_id' => 'exception-governance-home',
]);
FindingException::query()->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance home exception review',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory()
->forTenant($alphaTenant)
->create([
@ -87,13 +110,15 @@
->assertOk()
->assertSee('Assigned findings')
->assertSee('Findings intake')
->assertSee('Finding exceptions')
->assertSee('Operations follow-up')
->assertSee('Alert delivery failures')
->assertSee('Review follow-up')
->assertSee('Open my findings')
->assertSee('Open finding exceptions')
->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries')
->assertSee('Open review follow-up');
->assertSee('Open customer review workspace');
});
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
@ -141,3 +166,47 @@
->assertSee('No failed alert deliveries match this tenant filter right now.')
->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

@ -149,6 +149,45 @@ function errorPermission(string $key, array $features = []): array
CarbonImmutable::setTestNow();
});
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = app(PermissionPostureFindingGenerator::class);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$generator->generate($tenant, buildComparison([
missingPermission('Perm.A', ['policy-sync', 'backup']),
]));
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->firstOrFail();
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($finding->sla_days)->toBe(7)
->and($expectedDueAt)->toBe('2026-03-03T10:00:00+00:00');
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$result = $generator->generate($tenant, buildComparison([
missingPermission('Perm.A', ['policy-sync', 'backup']),
]));
$finding->refresh();
expect($result->findingsCreated)->toBe(0)
->and($result->findingsUnchanged)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe(7)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
CarbonImmutable::setTestNow();
});
// (5) Re-opens resolved finding when permission revoked again
it('re-opens resolved finding when permission is revoked again', function (): void {
[$user, $tenant] = createUserWithTenant();

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

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256ConfigureRunSupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256RunHandoffComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
function spec256OperationRun(Tenant $tenant): OperationRun
{
return OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'completed_at' => now(),
]);
}
it('creates an external ticket from the operation-run support action', function (): void {
spec256ConfigureRunSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = spec256OperationRun($tenant);
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-RUN-256',
], 201),
]);
spec256RunHandoffComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Run create external ticket handoff.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-256')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-256');
});
it('links an existing external ticket from the operation-run support action without outbound create', function (): void {
spec256ConfigureRunSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
$run = spec256OperationRun($tenant);
Http::fake();
spec256RunHandoffComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Run link existing external ticket.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-RUN-LINK',
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-LINK')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-LINK');
Http::assertNothingSent();
});
it('keeps the internal run support request when external create fails', function (): void {
spec256ConfigureRunSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = spec256OperationRun($tenant);
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
spec256RunHandoffComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Run external handoff failure should keep internal support request.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
->and($supportRequest->external_ticket_reference)->toBeNull()
->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout')
->and(OperationRun::query()->count())->toBe(1);
});

View File

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256ConfigureAuditSupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256AuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
it('preserves support request created audit and records external ticket created audit', function (): void {
spec256ConfigureAuditSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-AUDIT-CREATED',
'raw_secret' => 'must-not-be-copied',
], 201),
]);
spec256AuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Audit external ticket created.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
$createdAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestCreated->value)
->sole();
$externalAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestExternalTicketCreated->value)
->sole();
expect($createdAudit->resource_id)->toBe((string) $supportRequest->getKey())
->and($externalAudit->resource_id)->toBe((string) $supportRequest->getKey())
->and($externalAudit->tenant_id)->toBe((int) $tenant->getKey())
->and($externalAudit->status)->toBe('success')
->and(data_get($externalAudit->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
->and(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-CREATED')
->and((string) json_encode($externalAudit->metadata))->not->toContain('must-not-be-copied');
});
it('records external ticket linked audit without issuing outbound create', function (): void {
spec256ConfigureAuditSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
Http::fake();
spec256AuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Audit external ticket linked.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-AUDIT-LINKED',
])
->callMountedAction()
->assertHasNoActionErrors();
$externalAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestExternalTicketLinked->value)
->sole();
expect(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-LINKED')
->and($externalAudit->status)->toBe('success');
Http::assertNothingSent();
});
it('records external handoff failed audit with bounded failure metadata', function (): void {
spec256ConfigureAuditSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
spec256AuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Audit external ticket failure.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
$externalAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestExternalHandoffFailed->value)
->sole();
expect($externalAudit->resource_id)->toBe((string) $supportRequest->getKey())
->and($externalAudit->status)->toBe('failed')
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBeNull()
->and(data_get($externalAudit->metadata, 'external_handoff_failure_summary'))->toContain('configured timeout');
});

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256AuthorizationTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function spec256AuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
function spec256AuthorizationRun(Tenant $tenant): OperationRun
{
return OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
]);
}
it('keeps external handoff actions forbidden for entitled tenant members without support-create capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
spec256AuthorizationTenantComponent($user, $tenant)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeTenantSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});
it('keeps external handoff actions forbidden for entitled run viewers without support-create capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$run = spec256AuthorizationRun($tenant);
spec256AuthorizationOperationComponent($user, $run)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeOperationRunSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});
it('does not reveal latest tenant handoff summaries to workspace members without tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
SupportRequest::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-HIDDEN',
]);
try {
app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
$this->fail('Expected latest handoff summary to deny as not found.');
} catch (HttpExceptionInterface $exception) {
expect($exception->getStatusCode())->toBe(404);
}
});
it('does not reveal latest run handoff summaries outside the run tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = spec256AuthorizationRun($tenant);
SupportRequest::factory()
->forOperationRun($run)
->create([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-RUN-HIDDEN',
]);
try {
app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($run, $user);
$this->fail('Expected latest run handoff summary to deny as not found.');
} catch (HttpExceptionInterface $exception) {
expect($exception->getStatusCode())->toBe(404);
}
});

View File

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256ConfigureTenantSupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256TenantHandoffComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
it('creates an external ticket from the tenant dashboard support action', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create(['name' => 'Spec 256 Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-2561',
'ticket_url' => 'https://desk.example.test/tickets/PSA-2561',
], 201),
]);
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Tenant create external ticket handoff.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-2561')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-2561')
->and($supportRequest->external_handoff_failure_summary)->toBeNull()
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED);
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets'
&& data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference);
});
it('links an existing external ticket from the tenant dashboard without creating a duplicate external ticket', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
Http::fake();
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_NORMAL,
'summary' => 'Tenant link existing external ticket.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-256-LINK',
'external_ticket_url' => 'https://desk.example.test/tickets/PSA-256-LINK',
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-256-LINK')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-256-LINK')
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED);
Http::assertNothingSent();
});
it('rejects invalid linked external ticket input before storing a tenant support request', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
Http::fake();
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_NORMAL,
'summary' => 'Tenant invalid link should not create support truth.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'not a ticket',
])
->callMountedAction()
->assertHasErrors(['external_ticket_reference']);
expect(SupportRequest::query()->count())->toBe(0);
Http::assertNothingSent();
});
it('keeps the internal tenant support request when external create fails', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_BLOCKING,
'summary' => 'Tenant external desk timeout should keep internal support request.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->internal_reference)->toMatch('/^SR-/')
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and($supportRequest->external_ticket_reference)->toBeNull()
->and($supportRequest->external_ticket_url)->toBeNull()
->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout')
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED);
});
it('forces tenant support requests to internal only when no external target is configured', function (): void {
spec256ConfigureTenantSupportDesk([
'enabled' => false,
]);
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Http::fake();
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Tenant support stays internal when no support desk target exists.',
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->and($supportRequest->external_ticket_reference)->toBeNull()
->and($supportRequest->external_handoff_failure_summary)->toBeNull();
Http::assertNothingSent();
});

View File

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

View File

@ -4,6 +4,7 @@
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
@ -54,6 +55,28 @@
'subject_external_id' => 'intake-finding',
]);
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $workspace->getKey(),
'subject_external_id' => 'exception-finding',
]);
FindingException::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Needs approval',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory()
->forTenant($alphaTenant)
->create([
@ -129,6 +152,7 @@
visibleFindingTenants: [$alphaTenant, $bravoTenant],
reviewTenants: [$alphaTenant, $bravoTenant],
canViewAlerts: true,
canViewFindingExceptions: true,
navigationContext: $context,
);
@ -136,6 +160,7 @@
->toBe([
'assigned_findings',
'intake_findings',
'finding_exceptions',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
@ -143,6 +168,7 @@
->and($payload['family_counts'])->toMatchArray([
'assigned_findings' => 1,
'intake_findings' => 1,
'finding_exceptions' => 1,
'stale_operations' => 2,
'alert_delivery_failures' => 1,
'review_follow_up' => 2,
@ -153,6 +179,9 @@
expect($sections['assigned_findings']['dominant_action_url'])
->toContain('/admin/findings/my-work')
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
->and($sections['finding_exceptions']['dominant_action_label'])->toBe('Open finding exceptions')
->and($sections['finding_exceptions']['dominant_action_url'])->toContain('/admin/finding-exceptions/queue')
->and($sections['finding_exceptions']['entries'][0]['destination_url'])->toContain('exception='.(string) $sections['finding_exceptions']['entries'][0]['source_key'])
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
@ -197,3 +226,63 @@
->and($payload['sections'][0]['count'])->toBe(0)
->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

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
function configureSpec256SupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256SupportRequest(array $attributes = []): SupportRequest
{
$tenant = Tenant::factory()->create();
return SupportRequest::factory()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'summary' => 'Need external support desk handoff.',
'severity' => SupportRequest::SEVERITY_HIGH,
], $attributes));
}
it('creates an external ticket through the configured target and normalizes the returned reference', function (): void {
configureSpec256SupportDesk([
'api_token' => 'secret-token',
]);
$supportRequest = spec256SupportRequest();
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-12345',
'ticket_url' => 'https://desk.example.test/tickets/PSA-12345',
], 201),
]);
$result = app(ExternalSupportDeskHandoffService::class)->createTicket($supportRequest);
expect($result['successful'])->toBeTrue()
->and($result['external_ticket_reference'])->toBe('PSA-12345')
->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-12345')
->and($result['failure_summary'])->toBeNull();
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets'
&& data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference
&& data_get($request->data(), 'support_request.summary') === 'Need external support desk handoff.');
});
it('enforces the five second timeout budget and normalizes connection failures', function (): void {
configureSpec256SupportDesk([
'timeout_seconds' => 30,
]);
$supportRequest = spec256SupportRequest();
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
$service = app(ExternalSupportDeskHandoffService::class);
$result = $service->createTicket($supportRequest);
expect($service->timeoutSeconds())->toBe(5)
->and($result['successful'])->toBeFalse()
->and($result['external_ticket_reference'])->toBeNull()
->and($result['failure_summary'])->toContain('configured timeout');
});
it('falls back to unavailable when the single configured target is disabled', function (): void {
configureSpec256SupportDesk([
'enabled' => false,
]);
Http::fake();
$service = app(ExternalSupportDeskHandoffService::class);
$result = $service->createTicket(spec256SupportRequest());
expect($service->targetIsConfigured())->toBeFalse()
->and($result['successful'])->toBeFalse()
->and($result['failure_summary'])->toBe('External support desk target is not configured.');
Http::assertNothingSent();
});
it('normalizes linked tickets without issuing an outbound create call', function (): void {
configureSpec256SupportDesk();
Http::fake();
$result = app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket(' PSA-900 ', null);
expect($result['external_ticket_reference'])->toBe('PSA-900')
->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-900');
Http::assertNothingSent();
});
it('rejects invalid linked ticket input before storing external truth', function (): void {
configureSpec256SupportDesk();
expect(fn (): array => app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket('not a ticket', 'javascript:alert(1)'))
->toThrow(ValidationException::class);
});

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns the latest tenant-scoped handoff summary without using run-scoped requests', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
SupportRequest::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'internal_reference' => 'SR-OLDTENANT0000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-OLD',
'created_at' => now()->subMinutes(10),
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'created_at' => now(),
]);
SupportRequest::factory()
->forOperationRun($run)
->create([
'internal_reference' => 'SR-RUN000000000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-RUN',
'created_at' => now(),
]);
SupportRequest::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'internal_reference' => 'SR-NEWTENANT0000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_handoff_failure_summary' => 'External support desk did not respond before the configured timeout.',
'created_at' => now()->subMinute(),
]);
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
expect($summary)->not->toBeNull()
->and($summary['internal_reference'])->toBe('SR-NEWTENANT0000000000000001')
->and($summary['has_failure'])->toBeTrue()
->and($summary['has_external_link'])->toBeFalse()
->and($summary['external_handoff_failure_summary'])->toContain('configured timeout');
});
it('returns the latest run-scoped handoff summary for the current run only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$firstRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$secondRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
SupportRequest::factory()
->forOperationRun($secondRun)
->create([
'internal_reference' => 'SR-OTHERRUN0000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-OTHER',
'created_at' => now(),
]);
SupportRequest::factory()
->forOperationRun($firstRun)
->create([
'internal_reference' => 'SR-CURRENTRUN0000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-CURRENT',
'external_ticket_url' => 'https://desk.example.test/tickets/PSA-CURRENT',
'created_at' => now()->subMinute(),
]);
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($firstRun, $user);
expect($summary)->not->toBeNull()
->and($summary['internal_reference'])->toBe('SR-CURRENTRUN0000000000001')
->and($summary['external_ticket_reference'])->toBe('PSA-CURRENT')
->and($summary['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-CURRENT')
->and($summary['has_external_link'])->toBeTrue();
});

View File

@ -1,5 +1,10 @@
# TenantPilot / TenantAtlas — Handover Document
> **Status:** Needs Review
> **Last reviewed:** 2026-04-30
> **Use for:** Handover context, repo snapshot orientation, and migration-era operational notes
> **Do not use for:** Current implementation truth or current branch state without repo verification
> **Generated**: 2026-03-06 · **Branch**: `dev` · **HEAD**: `da1adbd`
> **Stack**: Laravel 12 · Filament v5 · Livewire v4 · PostgreSQL 16 · Tailwind v4 · Pest 4

View File

@ -1,154 +1,90 @@
# Microsoft Graph API Permissions
This document lists all required Microsoft Graph API permissions for TenantPilot to function correctly.
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Current repo-based Microsoft Graph permission reference for implemented platform features
> **Do not use for:** Future roadmap permissions or final tenant-specific grant truth without checking the repo and the live tenant posture
## Required Permissions
This document summarizes the permission registry currently defined in:
The Azure AD / Entra ID **App Registration** used by TenantPilot requires the following **Application Permissions** (not Delegated):
- `apps/platform/config/intune_permissions.php`
- `apps/platform/config/entra_permissions.php`
### Core Policy Management (Required)
- `DeviceManagementConfiguration.Read.All` - Read Intune device configuration policies
- `DeviceManagementConfiguration.ReadWrite.All` - Write/restore Intune policies
- `DeviceManagementApps.Read.All` - Read app configuration policies
- `DeviceManagementApps.ReadWrite.All` - Write app policies
These config files are the repo source of truth for currently implemented permission requirements.
### Scope Tags (Feature 004 - Required for Phase 3)
- **`DeviceManagementRBAC.Read.All`** - Read scope tags and RBAC settings
- **Purpose**: Resolve scope tag IDs to display names (e.g., "0" → "Default")
- **Missing**: Backup items will show "Unknown (ID: 0)" instead of scope tag names
- **Impact**: Metadata display only - backups still work without this permission
## Scope Rules
### Group Resolution (Feature 004 - Required for Phase 2)
- `Group.Read.All` - Resolve group IDs to names for assignments
- `Directory.Read.All` - Batch resolve directory objects (groups, users, devices)
- The list below describes the current repo-required Microsoft Graph permissions for implemented features.
- This document does not promote roadmap or research-only permissions to required status.
- `granted_stub` values in `intune_permissions.php` are display aids for the UI, not the canonical required-permission list.
- Unless stated otherwise, these are application permissions.
## How to Add Permissions
## Current Required Permissions
### Azure Portal (Entra ID)
### Intune Configuration, Backup, Restore, and Drift
1. Go to **Azure Portal****Entra ID** (Azure Active Directory)
2. Navigate to **App registrations** → Select your TenantPilot app
3. Click **API permissions** in the left menu
4. Click **+ Add a permission**
5. Select **Microsoft Graph** → **Application permissions**
6. Search for and select the required permissions:
- `DeviceManagementRBAC.Read.All`
- (Add others as needed)
7. Click **Add permissions**
8. **IMPORTANT**: Click **Grant admin consent for [Your Organization]**
- ⚠️ Without admin consent, the permissions won't be active!
| Permission | Why the repo requires it |
|---|---|
| `DeviceManagementConfiguration.Read.All` | Read Intune device configuration policies for inventory, backup, settings normalization, and drift flows |
| `DeviceManagementConfiguration.ReadWrite.All` | Execute restore and other write flows for Intune device configuration policies |
| `DeviceManagementApps.Read.All` | Read Intune app configuration and assignments for sync and backup |
| `DeviceManagementApps.ReadWrite.All` | Restore and manage Intune app configuration and assignments |
| `DeviceManagementServiceConfig.Read.All` | Read enrollment restrictions, Autopilot, ESP, and related service configuration |
| `DeviceManagementServiceConfig.ReadWrite.All` | Restore and manage enrollment restrictions, Autopilot, ESP, and related service configuration |
| `DeviceManagementScripts.Read.All` | Read device management scripts and remediations for sync and backup |
| `DeviceManagementScripts.ReadWrite.All` | Restore and manage device management scripts and remediations |
### PowerShell (Alternative)
### Conditional Access And Policy Coverage
```powershell
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "Application.ReadWrite.All"
| Permission | Why the repo requires it |
|---|---|
| `Policy.Read.All` | Read Conditional Access and related identity policy surfaces used for backup, preview, and versioning |
| `Policy.ReadWrite.ConditionalAccess` | Manage Conditional Access policies for controlled restore or admin-managed write paths |
# Get your app registration
$appId = "YOUR-APP-CLIENT-ID"
$app = Get-MgApplication -Filter "appId eq '$appId'"
### Directory, Groups, And Intune RBAC Foundations
# Add DeviceManagementRBAC.Read.All permission
$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$rbacPermission = $graphServicePrincipal.AppRoles | Where-Object {$_.Value -eq "DeviceManagementRBAC.Read.All"}
| Permission | Why the repo requires it |
|---|---|
| `Directory.Read.All` | Directory lookups and tenant-health-oriented checks |
| `Group.Read.All` | Assignment name resolution, group mapping, group directory cache, backup metadata enrichment, and drift context |
| `DeviceManagementRBAC.Read.All` | Read Intune RBAC settings and scope tags for metadata enrichment and assignment-aware flows |
| `DeviceManagementRBAC.ReadWrite.All` | Manage scope tags for foundation backup and restore workflows |
$requiredResourceAccess = @{
ResourceAppId = "00000003-0000-0000-c000-000000000000"
ResourceAccess = @(
@{
Id = $rbacPermission.Id
Type = "Role"
}
)
}
### Entra Admin Roles Evidence
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
| Permission | Why the repo requires it |
|---|---|
| `RoleManagement.Read.Directory` | Read directory role definitions and assignments for Entra admin roles evidence and findings |
# Grant admin consent
# (Must be done manually or via Graph API with RoleManagement.ReadWrite.Directory scope)
```
## Not Currently Required By Implemented Features
## Verification
These permissions may appear in research, roadmap ideas, or tenant-specific grants, but they are not part of the current required-permission registry:
After adding permissions and granting admin consent:
- `SharePointTenantSettings.Read.All` is a roadmap or research permission until SharePoint tenant settings are actually implemented.
- Exchange Online or Defender for Office 365 PowerShell permissions are not current repo requirements because those integrations are not implemented as production features.
- `DeviceManagementManagedDevices.ReadWrite.All` may appear in fixtures or grant stubs, but it is not listed in the current required-permission registry.
## Grant And Verify
1. In Entra ID, open the TenantPilot app registration.
2. Add the required Microsoft Graph application permissions from the tables above.
3. Grant admin consent for the tenant.
4. In the application, use the required-permissions or permission-posture surfaces to compare granted versus required permissions.
5. If the platform still shows stale permission state, clear caches with:
1. Go to **App registrations** → Your app → **API permissions**
2. Verify status shows **Granted for [Your Organization]** with a green checkmark ✅
3. Clear cache in TenantPilot:
```bash
php artisan cache:clear
```
4. Test scope tag resolution:
```bash
php artisan tinker
>>> use App\Services\Graph\ScopeTagResolver;
>>> use App\Models\Tenant;
>>> $tenant = Tenant::first();
>>> $resolver = app(ScopeTagResolver::class);
>>> $tags = $resolver->resolve(['0'], $tenant);
>>> dd($tags);
```
Expected output:
```php
[
[
"id" => "0",
"displayName" => "Default"
]
]
cd apps/platform && ./vendor/bin/sail artisan cache:clear
```
## Troubleshooting
## Least-Privilege Notes
### Error: "Application is not authorized to perform this operation"
**Symptoms:**
- Backup items show "Unknown (ID: 0)" for scope tags
- Logs contain: `Application must have one of the following scopes: DeviceManagementRBAC.Read.All`
**Solution:**
1. Add `DeviceManagementRBAC.Read.All` permission (see above)
2. **Grant admin consent** (critical step!)
3. Wait 5-10 minutes for Azure to propagate permissions
4. Clear cache: `php artisan cache:clear`
5. Test again
### Error: "Insufficient privileges to complete the operation"
**Cause:** The user account used to grant admin consent doesn't have sufficient permissions.
**Solution:**
- Use an account with **Global Administrator** or **Privileged Role Administrator** role
- Or have the IT admin grant consent for the organization
### Permissions showing but still getting 403
**Possible causes:**
1. Admin consent not granted (click the button!)
2. Permissions not yet propagated (wait 5-10 minutes)
3. Wrong tenant (check tenant ID in app config)
4. Cached token needs refresh (clear cache + restart)
## Feature Impact Matrix
| Feature | Required Permissions | Without Permission | Impact Level |
|---------|---------------------|-------------------|--------------|
| Basic Policy Backup | `DeviceManagementConfiguration.Read.All` | Cannot backup | 🔴 Critical |
| Policy Restore | `DeviceManagementConfiguration.ReadWrite.All` | Cannot restore | 🔴 Critical |
| Scope Tag Names (004) | `DeviceManagementRBAC.Read.All` | Shows "Unknown (ID: X)" | 🟡 Medium |
| Assignment Names (004) | `Group.Read.All` + `Directory.Read.All` | Shows group IDs only | 🟡 Medium |
| Group Mapping (004) | `Group.Read.All` | Manual ID mapping required | 🟡 Medium |
## Security Notes
- All permissions are **Application Permissions** (app-level, not user-level)
- Requires **admin consent** from Global Administrator
- Use **least privilege principle**: Only add permissions for features you use
- Consider creating separate app registrations for different environments (dev/staging/prod)
- Rotate client secrets regularly (recommended: every 6 months)
- Read-only evaluation or inventory-focused setups can often begin with the read permissions only.
- Any real restore or write lane needs the corresponding `ReadWrite` permission set.
- Conditional Access write access should be treated as a higher-risk permission and granted only when the restore or admin-write lane is intentionally enabled.
- Scope-tag restore paths require `DeviceManagementRBAC.ReadWrite.All`, not just the read permission.
## References
- [Microsoft Graph API Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference)
- [Intune Graph API Overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview)
- [App Registration Best Practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration)
- [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference)
- [Microsoft Intune Graph overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview)
- [App registration security best practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration)

View File

@ -2,6 +2,11 @@
---
> **Status:** Needs Review
> **Last reviewed:** 2026-04-30
> **Use for:** Fast repository orientation, stack overview, and high-level product scope context
> **Do not use for:** Current implementation truth or completion status without repo verification
**Overview:**
- **Purpose:** Intune management tool (backup, restore, policy versioning, safe change management) built with Laravel + Filament.
- **Primary goals:** policy version control (diff/history/rollback), reliable backup & restore of Microsoft Intune configuration, admin-focused productivity features, auditability and least-privilege operations.

60
docs/README.md Normal file
View File

@ -0,0 +1,60 @@
# TenantPilot Documentation Index
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Navigating current documentation sources and understanding how to maintain them with low overhead
> **Do not use for:** Assuming any document is implementation truth without repo verification
## Current Source of Truth
- `docs/product/roadmap.md` - current product roadmap and prioritization context
- `docs/product/spec-candidates.md` - active spec candidate queue
- `docs/product/principles.md` - product and architecture principles
- `docs/strategy/product-vision.md` - long-term product vision
- `docs/strategy/domain-coverage.md` - domain and coverage strategy
## Product Operations
- `docs/product/discoveries.md`
- `docs/product/implementation-ledger.md`
- `docs/product/prompts/`
- `docs/product/standards/`
## Technical Research
- `docs/research/`
## UI Standards
- `docs/ui/`
## Audits
- `docs/audits/`
## Security And Access References
- `docs/PERMISSIONS.md`
- `docs/security/`
## Historical Or Superseded Material
- `docs/audits/archive/`
- audit-derived candidate documents that are marked `Historical`, `Superseded`, or `Needs Review`
## Lightweight Maintenance Model
- Keep only the current source-of-truth documents actively maintained.
- Update a document when the underlying roadmap, policy, or decision actually changes.
- Mark older material with status headers instead of rewriting it to feel current.
- Prefer archive or superseded markers over deletion.
- Verify implementation claims against repo code, specs, and tests.
## Rules For Agents
- Treat docs as guidance, not implementation truth.
- Verify implementation claims against repo code.
- Specs are not automatically implemented.
- Tests are not automatically executed.
- Historical audits may be outdated.
- Prefer current roadmap and spec-candidates for prioritization.

View File

@ -1,5 +1,10 @@
# Audit-Derived Spec Candidates
> **Status:** Superseded
> **Last reviewed:** 2026-04-30
> **Use for:** Historical context on audit-driven candidate clustering from March 2026
> **Do not use for:** The current candidate queue; use `docs/product/spec-candidates.md` instead
**Date:** 2026-03-15
**Source audit:** [docs/audits/tenantpilot-architecture-audit-constitution.md](docs/audits/tenantpilot-architecture-audit-constitution.md) plus first-pass repo scan driven by [ .github/prompts/tenantpilot.audit.prompt.md ](.github/prompts/tenantpilot.audit.prompt.md)

10
docs/audits/README.md Normal file
View File

@ -0,0 +1,10 @@
# TenantPilot Audits
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Historical and current audit findings
> **Do not use for:** Current implementation truth without repo verification
Audits are point-in-time assessments. They may be outdated after implementation.
Use current source files, specs, tests, and product roadmap to verify whether a finding still applies.

View File

@ -1,5 +1,10 @@
# Enterprise Architecture Audit — TenantPilot / TenantAtlas
> **Status:** Historical
> **Last reviewed:** 2026-04-30
> **Use for:** Original architecture diagnosis and historical context for scope, panel, and RBAC design discussions
> **Do not use for:** Current architecture truth without repo verification
**Date:** 2026-03-09
**Auditor role:** Senior Enterprise SaaS Architect, UX/IA Auditor, Security/RBAC Reviewer
**Stack:** Laravel 12, Filament v5, Livewire v4, PostgreSQL, Tailwind v4

View File

@ -1,5 +1,10 @@
# Repo-wide Legacy / Orphaned Truth Audit
> **Status:** Needs Review
> **Last reviewed:** 2026-04-30
> **Use for:** Cleanup investigations and historical context on legacy truth collisions in the repo
> **Do not use for:** Current implementation truth without repo verification
**Date**: 2026-03-16
**Scope**: Full codebase — models, migrations, enums, services, jobs, observers, Filament resources, policies, capabilities, badges, tests, factories
**Method**: Systematic source-of-truth tracing across all layers

View File

@ -1,5 +1,10 @@
# Semantic Clarity — Spec Candidate Package
> **Status:** Superseded
> **Last reviewed:** 2026-04-30
> **Use for:** Historical reasoning behind the semantic clarity cleanup program and its original packaging
> **Do not use for:** The current candidate queue, current spec numbering, or current implementation truth without repo verification
**Source audit:** `docs/audits/semantic-clarity-audit.md`
**Date:** 2026-03-21
**Author role:** Senior Staff Engineer / Enterprise SaaS Product Architect

View File

@ -1,5 +1,10 @@
# TenantPilot Architecture Audit Constitution
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Audit standards, architectural review criteria, and repo-level safety review framing
> **Do not use for:** Current implementation truth or roadmap priority without repo verification
## Purpose
This constitution defines the non-negotiable architecture, security, and workflow rules for TenantPilot / TenantAtlas.

View File

@ -1,47 +1,25 @@
# Discoveries
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Parking implementation findings and follow-up ideas that are not yet part of the active roadmap or candidate queue
> **Do not use for:** Active priority order once an item is already tracked in the roadmap or spec-candidates
>
> Things found during implementation that don't belong in the current spec.
> Review weekly. Promote to [spec-candidates.md](spec-candidates.md) or discard.
Items that are already tracked in [spec-candidates.md](spec-candidates.md) or [roadmap.md](roadmap.md) should not remain here.
**Last reviewed**: 2026-03-15
**Last reviewed**: 2026-04-30
---
## 2026-03-15 — Queued execution trust relies too much on dispatch-time authority
## 2026-04-30 — 2026-03-15 architecture hardening cluster moved out of discoveries
- **Source**: architecture audit
- **Observation**: Queued jobs still rely too heavily on the actor, tenant, and authorization state captured at dispatch time. Execution-time scope continuity and reauthorization are not yet hardened as a canonical backend contract.
- **Category**: hardening
- **Priority**: high
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate A: queued execution reauthorization and scope continuity.
---
## 2026-03-15 — Tenant-owned query canon remains too ad hoc
- **Source**: architecture audit
- **Observation**: Tenant isolation is broadly present, but many tenant-owned reads still depend on repeated local `tenant_id` filtering instead of a reusable canonical query path. This increases drift risk and weakens wrong-tenant regression discipline.
- **Category**: hardening
- **Priority**: high
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate B: tenant-owned query canon and wrong-tenant guards.
---
## 2026-03-15 — Findings lifecycle truth is stronger in docs than in enforcement
- **Source**: architecture audit
- **Observation**: Findings workflow semantics are well-defined at spec level, but architectural enforcement still depends too much on service-path discipline. Direct or bypassing status mutations remain too plausible.
- **Category**: hardening
- **Priority**: high
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate C: findings workflow enforcement and audit backstop.
---
## 2026-03-15 — Livewire trust-boundary hardening is still convention-driven
- **Source**: architecture audit
- **Observation**: Complex Livewire and Filament flows still expose too much ownership-relevant context in public component state. This is not a proven exploit in the repo today, but the hardening standard is not yet explicit or reusable.
- **Category**: hardening
- **Priority**: medium
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate D: Livewire context locking and trusted-state reduction.
- **Observation**: The queued execution, tenant-query-canon, findings-enforcement, and Livewire trust-boundary items from the 2026-03-15 audit are now tracked through promoted specs and the roadmap hardening lane. They no longer belong in `discoveries.md` as open findings.
- **Category**: documentation
- **Priority**: low
- **Suggested follow-up**: Use `roadmap.md` and `spec-candidates.md` for current hardening follow-through; keep `discoveries.md` for new findings that are not yet tracked elsewhere.
---

View File

@ -1,5 +1,10 @@
# TenantPilot Implementation Ledger
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Repo-based implementation status and product-surface maturity assessment
> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch
## Purpose
Dieses Dokument beschreibt den aktuellen repo-basierten Implementierungsstand von TenantPilot. Es ergaenzt `roadmap.md` und `spec-candidates.md`, ersetzt sie aber nicht.
@ -15,7 +20,7 @@ ## Purpose
## Current Product Position
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls sowie einer repo-real umgesetzten ersten Customer-Review-Surface, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Die Customer-Review-Surface ist noch eher eine operator-led customer delivery view im Admin-Kontext als eine voll produktisierte, kundensichere Governance-of-Record Consumption-Flache; dazu bleiben Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
## Status Model
@ -41,24 +46,24 @@ ## Roadmap Coverage Summary
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|---|---|---:|---|---|---|---|
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. |
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen, aber die Customer-Consumption-Productization bleibt unvollstaendig. |
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. |
| UI & Product Maturity Polish | implemented_partial | strong | partial | partial repo tests, not run | no | Empty States, Navigation, Localization und read-only Review-Polish sind real, aber kein geschlossenes Theme-Completion-Signal. |
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. |
| R1.9 Platform Localization v1 | implemented_verified | strong | yes | repo tests, not run | foundation-only | Locale-Resolver, Override/Praeferenz, Workspace-Default, Fallback und lokalisierte Notifications sind repo-real. |
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real, aber die Customer-Review-Consumption ist noch nicht voll productized. |
| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
| Private AI Execution Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. |
| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. |
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow jenseits des jetzigen Exception-/Review-Layers. |
| Drift & Change Governance | implemented_partial | strong | yes | repo tests, not run | almost | Drift review, accepted-risk governance, exception validity und Governance-Inbox-Surfaces sind repo-real; portfolio-weite Eskalation bleibt offen. |
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
@ -69,10 +74,13 @@ ## Implemented Capabilities
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
| Findings inboxes and governance inbox | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Findings/MyFindingsInbox.php`; `app/Filament/Pages/Findings/FindingsIntakeQueue.php`; `app/Filament/Pages/Governance/GovernanceInbox.php`; `tests/Feature/Findings/MyWorkInboxTest.php`; `tests/Feature/Governance/*` |
| Finding exceptions and risk acceptance workflow | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/FindingException.php`; `app/Services/Findings/FindingExceptionService.php`; `app/Filament/Resources/FindingExceptionResource.php`; `tests/Feature/Findings/FindingExceptionWorkflowTest.php` |
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
| Customer review workspace | implemented_partial | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` |
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
@ -81,6 +89,7 @@ ## Implemented Capabilities
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
| Localization foundation | implemented_verified | yes | yes | repo tests, not run | partial | foundation-only | `app/Services/Localization/LocaleResolver.php`; `app/Http/Controllers/LocalizationController.php`; `tests/Feature/Localization/*` |
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
@ -99,14 +108,15 @@ ## Foundation-Only Capabilities
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
- Localization foundation: resolved locale precedence, Workspace-Default, User-Praeferenz/Override und Notification-Formatting sind real, aber Enablement statt eigener Produkt-Surface.
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
## Partial Capabilities
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten.
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber die Surface bleibt noch operator-led im Admin-Kontext; customer-safe wording, evidence summarization boundaries, audit-grade access semantics und calmer consumption states brauchen ein eigenes Productization-Follow-up.
- Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen.
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
@ -114,13 +124,12 @@ ## Partial Capabilities
## Planned But Not Implemented
- Platform Localization v1
- Private AI Execution & Usage Governance Foundation
- Private AI Execution Governance Foundation
- Human-in-the-Loop Autonomous Governance
- Standardization & Policy Quality / Intune Linting
- PSA / Ticketing Handoff
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1
- Policy Lifecycle / Ghost Policies
- Later compliance overlays beyond the current control/evidence foundation
## Release Readiness
@ -128,9 +137,10 @@ ## Release Readiness
| Release / Theme | Readiness | Notes |
|---|---|---|
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. |
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. |
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; die Customer-Review-Productization bleibt aber als sellability follow-up offen. |
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. |
| Compliance Evidence Mapping v1 | foundation only | Canonical Controls, Evidence, Stored Reports und Exceptions existieren als Grundlage; eine customer-safe Mapping-Layer ist nicht repo-proven. |
| Governance-as-a-Service Packaging v1 | foundation only | Review Packs, Exports, Evidence und Accepted-Risk-Truth sind repo-real; eine wiederholbare management-taugliche Governance-Verpackung ist nicht repo-proven. |
## Commercial Readiness
@ -138,14 +148,16 @@ ### Demo-ready
- Baseline compare and drift walkthroughs
- Review pack generation and export
- Customer review workspace walkthroughs with operator guidance
- Provider health, onboarding readiness and required permissions
- Support diagnostics
- Permission posture and Entra admin roles reporting
### Almost sellable
- Review-driven governance workflow around tenant reviews and review packs
- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs, aber noch nicht als vollstaendig productisierte customer-safe consumption experience
- Baseline drift and restore governance
- Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling
- Alerting and run visibility for governance operations
- Support requests with contextual diagnostics
- Provider readiness and permission posture reporting
@ -159,6 +171,7 @@ ### Foundation-only
- Canonical control catalog
- Stored reports substrate
- Evidence snapshot substrate
- Localization foundation
- Product telemetry
- Customer health scoring
- Operational controls
@ -166,51 +179,55 @@ ### Foundation-only
### Not sellable yet
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1
- Localization v1
- Compliance Evidence Mapping v1
- Governance-as-a-Service Packaging v1
- Private AI Execution Governance Foundation
- External Support Desk / PSA Handoff
- Compliance Light product layer
## Open Gaps & Blockers
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|---|---|---|---|---|
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
| Customer review productization remains incomplete | Sellability blocker | The repo has a real read-only customer review surface, but it still sits too close to operator/admin semantics and does not yet enforce a fully customer-safe consumption contract for findings, evidence, accepted risks, and audit-grade access/download flows | R2 completion / Customer review | P0 Customer Review Workspace Productization v1 |
| Decisioning still spans multiple repo-real inboxes | UX blocker | My Findings, Intake, Governance Inbox und Exception Queue sind real, aber Operators springen weiter zwischen mehreren Spezial-Surfaces und es gibt noch keinen portfolio-weiten Action-Layer | Findings Workflow / MSP Portfolio | P1 Governance Decision Surface Convergence |
| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces |
| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility |
| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants |
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
| Compliance-oriented control mapping is not productized | Moat blocker | Canonical controls and evidence exist, but the product still lacks a bounded customer-safe layer that maps technical truth into control/readiness language | Compliance Evidence Mapping | P2 Compliance Evidence Mapping v1 |
| Review truth is not yet packaged as a repeatable MSP deliverable | Sellability blocker | Review packs and evidence are real, but recurring management-ready governance packaging still depends on manual interpretation and presentation | Governance-as-a-Service Packaging | P2 Governance-as-a-Service Packaging v1 |
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment |
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution Governance | P3 Private AI Execution Governance Foundation |
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs still lag neuere Review-, Findings- und Localization-Surfaces | Product planning / roadmap maintenance | none - docs alignment |
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
## Recommended Next Specs
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
- `P0 Customer Review Workspace Productization v1`: turns the existing admin-plane handoff into a more explicit customer-safe review consumption contract with calmer wording, progressive disclosure, explicit access states, and auditable download/view semantics.
- `P1 Governance Decision Surface Convergence`: verbindet My Findings, Intake, Governance Inbox, Customer Review Workspace und Exception Queue zu weniger Operator-Journeys und bereitet die Portfolio-Ebene vor.
- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks.
- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases.
- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later.
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
- `P2 Compliance Evidence Mapping v1`: should start as one bounded versioned overlay that maps existing technical truth into one customer-safe control/readiness view and one reuse path into review or export surfaces.
- `P2 Governance-as-a-Service Packaging v1`: should start as one on-demand management-ready governance package built from existing review-pack, evidence, and accepted-risk truth rather than a broad recurring reporting suite.
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
## Roadmap Drift Notes
- `roadmap.md` understates current R2 implementation depth, but the ledger had overstated sellability. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind repo-real; the remaining gap is customer-safe productization, not review-foundation absence.
- `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo.
- `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert.
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented.
- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace.
- The roadmap is now better at describing still-missing portfolio- und commercial-Layer than the current state of review/findings/localization implementation. Cross-Tenant Compare and Promotion, full billing-state maturity, external PSA handoff and AI Governance still look genuinely unimplemented.
- The main drift pattern is still underestimation, but customer-review sellability now needs a more precise reading: the missing piece is no longer basic review read-only access, but the final customer-safe productization layer over an already real surface.
## Evidence Sources
@ -227,12 +244,19 @@ ## Evidence Sources
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/app/Filament/System/Pages/Dashboard.php`
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
Wichtige Models:
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Models/Finding.php`
- `apps/platform/app/Models/FindingException.php`
- `apps/platform/app/Models/FindingExceptionDecision.php`
- `apps/platform/app/Models/FindingExceptionEvidenceReference.php`
- `apps/platform/app/Models/BaselineProfile.php`
- `apps/platform/app/Models/BaselineSnapshot.php`
- `apps/platform/app/Models/EvidenceSnapshot.php`
@ -251,6 +275,7 @@ ## Evidence Sources
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- `apps/platform/app/Services/Alerts/AlertDispatchService.php`
- `apps/platform/app/Services/Findings/FindingExceptionService.php`
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
@ -258,6 +283,7 @@ ## Evidence Sources
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
- `apps/platform/app/Services/Localization/LocaleResolver.php`
Wichtige Test-Anker im Repo:
@ -276,4 +302,4 @@ ## Evidence Sources
## Last Updated
2026-04-27 on branch `248-private-ai-policy-foundation`
2026-04-29 on branch `platform-dev`

View File

@ -1,5 +1,10 @@
# Operator Semantic Taxonomy
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Canonical operator-facing vocabulary for lifecycle, outcome, evidence, and actionability states
> **Do not use for:** Inventing local synonyms or assuming every product surface already fully conforms without repo verification
>
> Canonical operator-facing state reference for the first implementation slice.
> Downstream specs and badge mappings must reuse this vocabulary instead of inventing local synonyms.

View File

@ -1,5 +1,10 @@
# Product Principles
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Stable product and architecture principles that should shape specs, UX, and implementation choices
> **Do not use for:** Assuming every principle is already enforced everywhere in code without repo verification
>
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
> New specs must align with these. If a principle needs to change, update this file first.

View File

@ -0,0 +1,11 @@
# Product & Roadmap Prompts
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Reusable roadmap, productization, and portfolio audit prompts
> **Do not use for:** Direct implementation without converting outputs into specs or verified planning artifacts
This folder contains reusable prompts for roadmap analysis, productization audits, and product strategy reviews.
Prompts are not specs.
Prompts are tools to generate, validate, or refine roadmap and spec-candidate decisions.

View File

@ -1,9 +1,16 @@
# Product Roadmap
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Current product roadmap, release themes, and prioritization context
> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification
>
> Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs.
>
> Queue boundary: the active candidate queue lives in `spec-candidates.md`; older audit-derived candidate packages are historical inputs only.
**Last updated**: 2026-04-25
**Last updated**: 2026-04-30
---
@ -16,6 +23,26 @@ ## Release History
| **R2 "Tenant Reviews, Evidence & Control Foundation"** | Evidence packs, stored reports, canonical control catalog, permission posture, alerts | **Partial** |
| **R2 cont.** | Alert escalation + notification routing | **Done** |
## Current Productization & Moat Priorities
This is the repo-based prioritization overlay for the next sellable lanes. The bottleneck is no longer raw backend truth alone. The next roadmap slices should make existing governance foundations customer-safe, decision-centered, auditable, and MSP-sellable before opening more backend-only islands.
| Order | Theme | Repo truth | Product posture | Why now | Candidate posture |
|---|---|---|---|---|---|
| 1 | Customer Review Workspace Productization v1 | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace, and accepted-risk foundations are repo-real | fast sellable | clearest sellability blocker between current repo truth and a customer-safe governance-of-record surface | active P0 candidate |
| 2 | Risk Acceptance & Accountability productization | Exception / risk-acceptance workflow is repo-verified, but customer-safe accountability presentation is not fully productized | fast sellable | strong MSP and German midmarket moat around documented decisions, expiry, reviewability, and audit trail | fold into Customer Review Workspace Productization and review/reporting follow-through, not a new greenfield foundation |
| 3 | Governance Decision Surface Convergence | Governance Inbox, My Findings, Intake, and Exception Queue are repo-real, but convergence is not | almost | reduces admin-tool sprawl and turns multiple queue surfaces into calmer decision work | active P1 candidate |
| 4 | Compliance Evidence Mapping v1 | Canonical controls, evidence, stored reports, reviews, and findings foundations are repo-real; customer-safe compliance mapping is not | foundation-only | strong governance moat for compliance-oriented MSP and Mittelstand reviews without certification claims | active P2 candidate |
| 5 | Governance-as-a-Service Packaging v1 | Review packs, exports, evidence, and accepted-risk foundations are repo-real; recurring executive/MSP packaging is not | foundation-only | turns governance truth into a repeatable MSP deliverable instead of one-off manual reporting | active P2 candidate |
| 6 | Cross-Tenant Compare & Promotion v1 | Portfolio triage exists; compare and promotion are not repo-proven | not implemented | strongest MSP multiplier after customer-safe review and decision workflows are calmer | active P1 candidate |
| 7 | Private AI Execution Governance Foundation | Spec 248 exists, but no repo-real governed AI execution layer is proven | only spec / not implemented | strategic moat later, but not ahead of current productization and portfolio-action gaps | keep as later strategic lane, not near-term blocker |
Explicit anti-sprawl boundaries for this priority set:
- Do not reopen risk acceptance as a broad new foundation theme; reuse the existing exception/risk-acceptance workflow and productize its customer-safe accountability trail.
- Do not reopen private AI as a fresh roadmap idea; the foundation already exists at spec level and should remain behind current customer-facing and MSP-facing sellability gaps.
- Do not prioritize Tenant Trust Score / public governance profile, insurance connectors, Copilot shadow-IT governance, local-first/on-prem proxy, or a standalone Betriebsrat mode before customer-safe review consumption, decision convergence, compliance mapping, governance packaging, and compare/promotion are materially clearer.
---
## Active / Near-term
@ -51,6 +78,8 @@ ### R1.9 Platform Localization v1 (DE/EN)
UI-Sprache umschaltbar (`de`, `en`) mit sauberem Locale-Foundation-Layer.
Goal: Konsistente, durchgängige Lokalisierung aller Governance-Oberflächen — ohne Brüche in Export, Audit oder Maschinenformaten.
Repo reality: Die Locale-Foundation ist bereits repo-real. Der verbleibende Gap ist kein greenfield Localization-Foundation-Spec mehr, sondern Surface-Adoption, Copy-/Glossary-Vervollständigung und Regression-Hardening auf customer- und governance-nahen Oberflächen.
- Locale-Priorität: expliziter Override → User Preference → Workspace Default → System Default
- Workspace Default Language für neue Nutzer, User kann persönliche Sprache überschreiben
- Core-Surfaces zuerst: Navigation, Dashboard, Tenant Views, Findings, Baseline Compare, Risk Exceptions, Alerts, Operations, Audit-nahe Grundtexte
@ -64,7 +93,7 @@ ### R1.9 Platform Localization v1 (DE/EN)
- Search/Sort/Filter auf kritischen Listen für locale-sensitives Verhalten prüfen
- QA/Foundation: Missing-Key Detection, Locale Regression Tests, Pseudolocalization Smoke Tests für kritische Flows
**Active specs**: — (not yet specced)
**Queue status**: no standalone active candidate right now; remaining localization work should be folded into customer-facing productization and UI-maturity follow-through unless a narrower repo-real gap emerges.
### Product Scalability & Self-Service Foundation
Self-service and supportability foundation that keeps TenantPilot operable as a low-headcount, AI-assisted SaaS instead of drifting into manual onboarding, manual support, and founder-dependent customer operations.
@ -110,10 +139,10 @@ ### R1.x Foundation Hardening — Governance Platform Anti-Drift
### R2 Completion — Evidence & Exception Workflows
- Review pack export (Spec 109 — done)
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
- Exception/risk-acceptance workflow for Findings → Spec 154 (repo-real foundation; the next product gap is accountability-trail productization in customer-safe review, expiry/re-review visibility, and management-ready reporting)
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
- Workspace-level PII override for review packs → deferred from 109
- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions
- Customer Review Workspace Productization v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, calmer access states, and no admin/remediation actions
- Support Diagnostic Pack → connect tenant/review/finding/report/operation contexts into a reusable support bundle before support demand scales
- In-App Support Request with Context → attach the relevant diagnostic pack and ticket reference to support workflows without creating a separate support data model
- Product Knowledge & Contextual Help → reuse canonical glossary, outcome/reason semantics, and report/finding terminology as the product-help source layer
@ -127,10 +156,24 @@ ### Findings Workflow v2 / Execution Layer
- Reuse the existing alerting foundation for assignment, reopen, due-soon, and overdue notification flows
- Keep comments, external ticket handoff, and cross-tenant workboards as later slices instead of forcing them into the first workflow iteration
### Policy Lifecycle / Ghost Policies
Soft delete detection, automatic restore, "Deleted" badge, restore from backup.
Draft exists (Spec 900). Needs spec refresh and prioritization.
**Risk**: Ghost policies create confusion for backup item references.
### Workspace, Tenant & Managed Object Lifecycle Governance
Strategic lifecycle taxonomy for workspaces, tenants, managed provider objects, evidence, backups, restoreability, export, retention, and purge.
**Goal**: Prevent local lifecycle fixes such as “Ghost Policies” from introducing inconsistent deletion semantics before TenantPilot has one enterprise-grade lifecycle model.
TenantPilot must distinguish at least these lifecycle dimensions:
- Local record lifecycle: active, archived, locally removed, purge scheduled, purged
- Provider presence lifecycle: present, missing from provider, provider deleted, reappeared
- Operator suppression lifecycle: visible, ignored / suppressed, restored to visibility
- Commercial / workspace lifecycle: trial, active, grace, suspended read-only, closed
- Retention / compliance lifecycle: retained, export requested, deletion requested, deletion scheduled, legal hold / retention hold, purge due, purged
- Restoreability lifecycle: restorable, metadata only, blocked by dependency, not restorable, expired by retention
**Roadmap posture**: Strategic P2 enterprise-trust candidate, not immediate implementation. This should not block Customer Review Workspace Productization, Governance Decision Surface Convergence, or Cross-Tenant Compare & Promotion.
**Important boundary**: Do not implement a narrow policy-only ghost lifecycle patch, Laravel `SoftDeletes` rollout, workspace deletion flow, tenant deletion flow, purge engine, or retention framework before this lifecycle taxonomy is agreed.
**Spec candidate**: `Workspace, Tenant & Managed Object Lifecycle Governance v1` in `docs/product/spec-candidates.md`.
### Platform Operations Maturity
- CSV export for filtered run metadata (deferred from Spec 114)
@ -166,7 +209,7 @@ ### Additional Solo-Founder Scale Guardrails
- Vendor Questionnaire Answer Bank: reusable security/procurement answers aligned with the Security Trust Pack, product data model, Microsoft permissions, hosting, AI usage, subprocessors, retention, backup, deletion, and incident handling
- Product Intake & No-Customization Governance: feature-request intake, roadmap-fit classification, no-custom-work policy, customer exception handling, productization rules, and a clear path from request → candidate → spec → release or rejection
- Support Severity Matrix & Runbooks: P1P4 definitions, incident vs support vs bug vs feature request distinction, response expectations by plan, escalation rules, known-issue handling, and internal support runbooks
- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility
- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility; depends on the shared Workspace, Tenant & Managed Object Lifecycle Governance taxonomy before destructive or retention-sensitive flows are implemented
- Business Continuity / Founder Backup Plan: access documentation, secret management, emergency contacts, deployment and restore runbooks, incident templates, DNS/domain/hosting ownership, billing access, and vacation/sickness fallback
**Active specs**: — (not yet specced; guardrail track, only product-impacting items should become specs)
@ -182,12 +225,13 @@ ### Product Usage, Customer Health & Operational Controls
**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation.
**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice.
### Private AI Execution & Usage Governance Foundation
### Private AI Execution Governance Foundation
Strategic AI platform foundation for using AI inside TenantPilot without hard-coding public cloud AI calls, leaking tenant data, losing cost control, or forcing later rewrites.
**Goal**: Make AI local/private-first, explicitly governed, budgeted, cacheable, auditable, and human-approved. External public AI providers are disabled by default and only usable through workspace-level opt-in, data classification, redaction, usage limits, and approval gates.
**Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI cannot be bolted on through direct feature-level API calls. The platform needs a reusable execution boundary so support summaries, finding explanations, review packs, decision packs, and customer communications can use AI later without rebuilding privacy, cost, provider, approval, and audit controls each time.
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, Product Usage & Adoption Telemetry, Plans / Entitlements & Billing Readiness, Operational Controls & Feature Flags, Security Trust Pack Light, audit log foundation, and workspace/RBAC isolation.
**Scope direction**: Build the foundation before broad AI features: AI use case registry, AI provider registry, workspace AI policy, AI data classification, AI context builders, AI policy gate, AI budget gate, AI result store/cache, AI usage ledger, and AI audit trail. Start with local/private and customer-hosted model compatibility; keep external provider support optional and explicit.
**Priority note**: This remains strategic, but it should stay behind current customer-review productization, decision convergence, compliance mapping, governance packaging, and compare/promotion gaps.
**Core principles**:
- AI is never called directly from feature code; every AI action goes through governed use cases, policy gates, budget gates, context builders, provider adapters, cache/result storage, and audit trails
@ -212,7 +256,7 @@ ### AI-Assisted Customer Operations
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by private AI execution policy, human approval, and product auditability.
**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval.
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation or uncontrolled public-model data processing.
**Depends on**: Private AI Execution & Usage Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
**Depends on**: Private AI Execution Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Prefer local/private execution for tenant/customer data. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
### Decision-Based Operating Foundations
@ -221,12 +265,14 @@ ### Decision-Based Operating Foundations
**Why it matters**: Governance inboxes, actionable alerts, and later autonomous-governance features will fail if they land on top of detail-heavy, entity-first navigation. This is the UX/product prerequisite layer for the later MSP Portfolio OS direction.
**Depends on**: Current constitution and action-surface hardening, operator-truth work, existing navigation/context specs.
**Scope direction**: First the constitution/rule delta, then a surface / IA classification of current product surfaces, then bounded retrofits that demote detail-first flows behind progressive disclosure instead of creating more top-level pages.
**Concrete next slice**: `Governance Decision Surface Convergence` is the repo-based follow-up. Do not reopen the first Governance Inbox as a greenfield concept.
### MSP Portfolio & Operations (Multi-Tenant)
Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center.
**Source**: 0800-future-features brainstorming, identified as highest priority pillar.
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
**Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable.
**Concrete next slice**: `Cross-Tenant Compare & Promotion v1` is the repo-based move from portfolio visibility toward portfolio action.
### Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating)
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the Microsoft-first workspace portfolio, while keeping the decision model provider-extensible for later non-Microsoft domains.
@ -260,12 +306,12 @@ ### PSA / Ticketing Handoff
Outbound handoff from findings into external service-desk or PSA systems with visible `ticket_ref` linkage and auditable "ticket created/linked" events.
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
### Compliance Readiness & Executive Review Packs
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, canonical control coverage, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views depend on the Canonical Control Catalog and Evidence-to-Control mapping and remain explicitly without certification claims. Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
**Depends on**: Canonical Control Catalog Foundation, Evidence-to-Control mapping, StoredReports / EvidenceItems foundation, Tenant Review runs, Customer Review Workspace / Read-only View, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
### Compliance Evidence Mapping v1
Versioned mapping layer that connects technical findings, accepted risks, evidence, and review outcomes to customer-safe control and readiness views without certification claims.
**Goal**: Translate existing governance truth into control- and audit-ready language for German midmarket and compliance-oriented customers while keeping technical findings clearly separate from regulatory interpretation.
**Why it matters**: Canonical controls and evidence are already repo-real foundations. The missing value is not another control catalog, but a customer-safe mapping layer that explains why a finding matters, what evidence exists, and which control or readiness statement it supports.
**Depends on**: Canonical Control Catalog Foundation, Evidence-to-Control mapping, StoredReports / EvidenceItems foundation, Tenant Review runs, Customer Review Workspace Productization, Findings + Risk Acceptance workflow, and export maturity.
**Scope direction**: Start with versioned control interpretations plus one bounded overlay layer. Avoid certification promises, legal guarantees, or framework-specific deep branching inside the platform core.
**Modeling principle**: Compliance and governance requirements are modeled through a framework-neutral canonical control catalog plus technical interpretations and versioned framework overlays, not as separate technical object worlds per framework. Readiness views, evidence packs, baseline libraries, and auditor outputs are generated from that shared domain model.
**Layering**:
@ -280,6 +326,13 @@ ### Compliance Readiness & Executive Review Packs
- Keep ISO / COBIT semantics in governance-assurance and ISMS-oriented overlays rather than introducing a second technical control universe
- Avoid framework-specific one-off reports that bypass the common evidence, findings, exception, and export pipeline
### Governance-as-a-Service Packaging v1
Recurring governance deliverables for MSPs and customer stakeholders built on review packs, accepted risks, evidence, and control mapping.
**Goal**: Let MSPs deliver monthly or quarterly governance packages without manual screenshot decks, Excel exports, or ad-hoc PowerPoint work.
**Why it matters**: This is the commercial layer that turns already-strong review, evidence, and accepted-risk foundations into a repeatable MSP revenue surface. TenantPilot should sell not only truth capture, but calm customer-safe governance reporting.
**Depends on**: Customer Review Workspace Productization, Compliance Evidence Mapping v1, Review Packs, StoredReports / EvidenceItems, Findings + Risk Acceptance workflow, export pipeline maturity, localization, and entitlements.
**Scope direction**: Start with executive summary, top findings, accepted risks, open decisions, evidence links, management-ready review-pack export, and bounded MSP branding. Avoid CRM/newsletter tooling, PSA replacement, or raw operator-data dumps as the default output.
### Entra Role Governance
Expand TenantPilot's governance coverage into Microsoft Entra role definitions and assignments as a first-class identity administration surface.
**What it means**: Inventory and visibility for built-in and custom role definitions. Visibility into role assignments and governance-relevant changes. Review-ready representation of identity administration posture.
@ -331,15 +384,15 @@ ## Infrastructure & Platform Debt
| Item | Risk | Status |
|------|------|--------|
| No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness |
| No product-level entitlement foundation yet | Later pricing, trial, retention, export, user, and tenant limits may require invasive retrofits | Covered by Product Scalability & Self-Service Foundation |
| No shared lifecycle taxonomy for workspace, tenant, managed-object, retention, export, purge, and restoreability states | Local fixes such as ghost-policy handling, workspace deactivation, tenant removal, retention, or purge can create inconsistent deletion semantics and audit gaps | Covered by Workspace, Tenant & Managed Object Lifecycle Governance candidate |
| No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation |
| No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness |
| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails |
| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails |
| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails |
| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution & Usage Governance Foundation |
| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution & Usage Governance Foundation |
| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution & Usage Governance Foundation |
| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution Governance Foundation |
| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution Governance Foundation |
| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution Governance Foundation |
| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails |
| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails |
| No `.env.example` in repo | Onboarding friction | Open |
@ -356,7 +409,7 @@ ## Priority Ranking (from Product Brainstorming)
1. Product Scalability & Self-Service Foundation
2. Product Usage, Customer Health & Operational Controls
3. Private AI Execution & Usage Governance Foundation
3. Private AI Execution Governance Foundation
4. Decision-Based Operating / Governance Inbox
5. MSP Portfolio + Alerting
6. Drift + Approval Workflows
@ -373,6 +426,7 @@ ## How to use this file
- **Big product and operating themes** live here.
- **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md)
- **Lifecycle / deletion / retention work must be taxonomy-first**: do not promote narrow ghost-policy, workspace deletion, tenant deletion, purge, or retention specs until the shared Workspace, Tenant & Managed Object Lifecycle Governance candidate defines the platform semantics.
- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates.
- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows.
- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions.

View File

@ -1,9 +1,14 @@
# Spec Candidates
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
>
> Repo-based next-spec queue for TenantPilot.
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
> **Last reviewed**: 2026-04-28
> **Last reviewed**: 2026-04-30
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
---
@ -19,70 +24,115 @@ ## Candidate Rules
- P3 is for later platform ambitions after current release blockers close.
- Existing candidate history is preserved through `Promoted to Spec`, `Deferred`, and `Superseded / Removed` notes rather than silent deletion.
## Current Source-Of-Truth Boundary
- This file is the active candidate queue.
- `roadmap.md` provides strategic themes and release framing, not the canonical candidate queue.
- `discoveries.md` is a staging area for findings that may later be promoted here.
- `implementation-ledger.md` is maturity evidence, not a prioritization queue.
- Audit-derived candidate packages under `docs/audits/` are historical inputs only unless they are explicitly promoted into this file.
## Active Candidate Queue
### P0 — Release Blockers
### Customer Review Workspace v1
### Customer Review Workspace Productization v1
- **Priority**: P0
- **Why this stays active**: The repo already has strong internal review foundations: tenant reviews, evidence snapshots, review packs, redaction paths, entitlements, audit, and RBAC-aware surfaces. What is still missing is the customer-safe read-only consumption layer that turns those internal assets into a clearly sellable review product.
- **Roadmap relationship**: R2 completion / customer-facing review consumption.
- **Candidate type**: Productization / customer-safe consumption.
- **Why this stays active**: The repo already has strong review foundations plus a repo-real `CustomerReviewWorkspace` page from Spec 249. What remains open is productization: the current surface still behaves more like an operator-led customer delivery view inside the admin plane than a fully customer-safe governance-of-record consumption experience.
- **Roadmap relationship**: R2 completion / customer-facing review consumption and sellability polish.
- **Existing implementation context**: Spec 249 (`customer-review-workspace`) delivered the first read-only workspace handoff. This candidate is the bounded follow-up that hardens the existing surface into a clearer customer-safe product contract instead of reopening review foundations from scratch.
- **Goal**: Turn the existing customer review surface into a customer-safe, read-only review consumption experience for customer reviewers, customer admins, and auditors that answers what was reviewed, what is critical, what was accepted, what evidence exists, and what the next sensible step is.
- **Dependencies**:
- `TenantReview`
- `EvidenceSnapshot`
- `ReviewPack`
- `EvidenceSnapshot`
- `Finding`
- accepted risks / exceptions workflow
- existing redaction behavior
- stored reports and canonical control catalog foundations
- workspace entitlements
- tenant/workspace RBAC and audit foundations
- tenant/workspace RBAC, audit, localization, and workspace-isolation foundations
- **Scope**:
- customer-safe read-only workspace or view for latest review state
- latest findings and accepted risks in customer-safe form
- review-pack download surface with existing redaction rules
- explicit absence of admin or remediation actions
- clear authorization boundaries for customer and read-only viewers
- productize the existing customer review workspace into a clearer customer-safe read-only review consumption surface
- keep the primary surface centered on customer review workspace, review detail, findings summary, accepted-risk summary, evidence summary, and review-pack download areas
- visible findings semantics with severity, status, reason, impact, and recommendation in customer-safe language
- accepted risks / exceptions shown as understandable governance decisions rather than internal workflow residue, including decision reason, accountable person or role, decision timing, expiry / re-review state, evidence linkage, and review context in customer-safe language
- evidence snapshots shown as narrative summaries and proof pointers, not raw JSON or provider payloads
- review-pack download area with existing redaction and entitlement rules
- clearer control / baseline context and next-step guidance for customer reviewers, customer admins, and auditors
- explicit audit events for workspace access, review detail access, evidence summary access, and pack downloads
- explicit empty, permission, expired, and unavailable states
- DE/EN-ready labels for customer-facing review text
- progressive disclosure for technical detail instead of operator-default density
- **Non-scope**:
- admin settings
- remediation actions
- raw operator diagnostics
- a broader customer portal rewrite
- billing or contract workflows
- a new customer portal, separate identity plane, or broader customer product shell
- policy remediation, restore, or admin actions for customers
- a new review engine, evidence engine, or report-generation engine
- AI-generated summaries
- public-link sharing without authentication or RBAC
- raw operator diagnostics, provider-debug data, or raw evidence payloads as default-visible content
- **Decision workflow**:
- customer reviewers can read released reviews, evidence summaries, and review packs
- customer acknowledgement can return later as a narrower v1.1 or v2 follow-up
- no technical remediation or admin mutation lives in this workspace
- **Capability review**:
- validate or introduce customer-facing capability boundaries such as `reviews.customer.view`, `reviews.customer.download_pack`, `evidence.customer.view`, `findings.customer.view`, and `risks.customer.view`
- existing operator capabilities must not automatically imply customer access
- **Export posture**:
- review packs remain the primary export and proof artifact
- evidence stays narrative and customer-safe by default rather than raw-payload first
- downloads must remain auditable
- **Acceptance criteria**:
- an authorized customer or read-only actor can open the review workspace
- latest review status, accepted risks, and key findings are visible without exposing admin controls
- review-pack downloads respect existing redaction and entitlement rules
- the customer-facing review workspace does not expose internal run, debug, provider, or raw JSON details by default
- findings are shown with severity, status, reason, impact, and recommendation in customer-safe wording
- accepted risks / exceptions are visible and understandable for non-operator consumers, including accountable role or person, decision timing, expiry or review status, and evidence linkage where product truth exists
- evidence is summarized without exposing raw payloads by default
- review packs are downloadable when entitlement and capability checks pass
- each relevant view and download action produces audit evidence
- tenant and workspace isolation are enforced and tested
- audit-sensitive or operator-only data is not exposed through this surface
- **Notes**: This is the clearest repo-derived blocker between current internal review strength and a cleaner sellable release.
- permission gaps and expired or unavailable access states are explicit and calm
- customer-facing labels are localization-ready
- global search does not leak customer review or evidence artifacts into unintended discovery paths
- **Notes**: This is still the clearest repo-derived P0 blocker between today's operator-strong review foundations and a cleaner customer-safe sellable release. Do not split a second broad liability / accountability foundation candidate out of this unless a narrower internal expiry-cockpit or portfolio-risk gap proves separate product value.
### P1 — Enterprise Maturity
### Decision-Based Governance Inbox v1
### Governance Decision Surface Convergence
- **Priority**: P1
- **Why this stays active**: Findings, alerts, operation runs, review-pack generation, and portfolio triage already exist, but operators still work across several surfaces. The next maturity step is a single decision-oriented work surface, not more raw detail pages.
- **Why this stays active**: The repo already has Governance Inbox, My Findings, Intake, Exception Queue, alerts, and review-linked action entry points. The open gap is no longer the first governance inbox; it is convergence across these repo-real surfaces so operators stop hopping between adjacent work queues.
- **Roadmap relationship**: Findings workflow maturity; later MSP Portfolio OS prerequisite.
- **Existing implementation context**:
- Spec 250 (`decision-governance-inbox`) delivered the first decision-oriented governance inbox surface.
- Specs 221, 222, 224, 225, 230, and 231 already cover major inbox, intake, notification, and workflow-adoption slices.
- `CustomerReviewWorkspace` and `FindingExceptionsQueue` now act as adjacent decision surfaces that should converge around one calmer operator journey instead of multiplying parallel entry points.
- **Dependencies**:
- findings workflow semantics and inbox foundations from Specs 219, 221, 222, 224, 225, 230, 231
- alert routing foundation
- `OperationRun` truth
- portfolio triage continuity
- customer review and exception governance surfaces where decision work overlaps
- contextual help and reason-code surfaces where helpful
- **Scope**:
- one operator-facing inbox for high-signal governance work
- grouping or prioritization across findings, alerts, stale runs, and related attention signals
- direct action links into compare, finding review, review-pack generation, or triage paths
- auditable state changes such as snooze, assign, or acknowledge where already supported
- one decision-centered operator entry model across more than one existing queue or signal family
- reduce surface-hopping between My Findings, Intake, Governance Inbox, Exception Queue, and adjacent high-signal attention states
- preserve direct action links into compare, finding review, review-pack generation, exception handling, or triage paths instead of duplicating domain state
- add convergence rules, prioritization, and clearer routing before inventing more list surfaces
- auditable state changes such as snooze, assign, or acknowledge only where those state mutations already exist as product truth
- **Non-scope**:
- rebuilding the first governance inbox from scratch
- autonomous remediation
- AI-generated recommendations
- customer-facing inboxes
- full cross-tenant workboard redesign
- **Acceptance criteria**:
- one surface shows prioritized governance work from more than one underlying signal family
- actions route to existing product truth rather than duplicating state
- operators can start from one decision-centered surface or convergence model that spans more than one existing signal family or queue
- existing surfaces keep one consistent routing model instead of growing more parallel queue concepts
- actions route to existing product truth rather than creating duplicate state or duplicate work ownership
- visibility is capability-aware and workspace-safe
- auditable state changes are recorded where the inbox mutates work state
- tests prove signal grouping and authorization boundaries
- **Notes**: Important, but not a P0 release blocker while Customer Review Workspace is still missing.
- tests prove signal grouping, routing, and authorization boundaries
- **Notes**: This is a follow-up to the existing Governance Inbox, not a greenfield inbox foundation.
### Cross-Tenant Compare and Promotion v1
- **Priority**: P1
@ -112,32 +162,6 @@ ### Cross-Tenant Compare and Promotion v1
- audit trail exists for compare and promotion entry points
- the slice refreshes or narrows Spec 043 instead of reopening it as a vague ambition
### Localization v1
- **Priority**: P1
- **Why this stays active**: The repo and roadmap both indicate this is still absent. It is not a backend foundation gap; it is a product maturity gap that will get more expensive as the governance surface grows.
- **Roadmap relationship**: R1.9 Platform Localization v1.
- **Dependencies**:
- existing status and terminology catalogs
- contextual help boundaries
- notification and UI copy inventory on critical surfaces
- locale resolution rules for workspace, user, and system context
- **Scope**:
- `de` and `en` on core governance surfaces
- locale resolution order and fallback behavior
- locale-aware formatting for dates, times, and numbers
- stable machine and export formats that remain non-localized
- **Non-scope**:
- public website localization
- broad documentation translation
- retrospective translation of every legacy free-text record
- marketing copy systems
- **Acceptance criteria**:
- core navigation, dashboard, findings, baseline compare, alerts, and operations surfaces support `de` and `en`
- no raw translation keys appear on critical UI paths
- fallback to English is controlled and predictable
- locale-aware formatting does not affect audit or export truth
- targeted regression coverage exists for fallback and key critical flows
### Remove Findings Lifecycle Backfill Runtime Surfaces
- **Priority**: P1
- **Why this stays active**: Repo audit shows visible runtime surfaces for a pre-production findings lifecycle repair path even though active finding generators already write the relevant lifecycle fields directly. The remaining path is not just ballast; it appears partially detached from current operational-control truth and keeps internal repair tooling productized.
@ -256,6 +280,63 @@ ### Commercial Entitlements and Billing-State Maturity
- changes and overrides are audited
- tests cover blocked and allowed paths
### Compliance Evidence Mapping v1
- **Priority**: P2
- **Why this stays active**: Canonical control catalog, evidence snapshots, stored reports, review packs, findings, and accepted-risk foundations are already repo-real. The missing gap is a versioned mapping layer from technical governance truth to customer-safe control or readiness views, not another control foundation rewrite.
- **Roadmap relationship**: Compliance moat / executive review follow-through.
- **Dependencies**:
- canonical control catalog foundation
- evidence snapshots and stored reports
- findings and accepted-risk workflow
- tenant reviews and review-pack export
- customer review productization and export maturity
- **Scope**:
- one versioned control interpretation layer and one bounded overlay for a first customer-safe readiness/control view
- map findings, evidence, and accepted risks to customer-safe control views without certification claims
- show control, evidence, and recommendation linkage in one primary review or export surface before broad multi-surface rollout
- keep framework overlays downstream from the shared canonical control model
- **Non-scope**:
- certification claims or legal guarantees
- hard-coded BSI, NIS2, CIS, or ISO semantics deep in the platform core
- separate technical control object models per framework
- full GRC suite or lawyer-facing workflow
- **Acceptance criteria**:
- one bounded overlay maps existing technical truth to a control or readiness view
- one concrete review or export surface can show control status, evidence linkage, and recommended action from shared foundations
- mapping versions are explicit and auditable
- the product clearly separates technical findings from regulatory interpretation
- no framework-specific one-off output bypasses the common evidence, findings, exception, and export pipeline
- **Smallest useful v1**: start with one overlay family and one customer-safe output path. Do not start by modeling multiple frameworks, multiple customer profiles, and multiple output surfaces at once.
### Governance-as-a-Service Packaging v1
- **Priority**: P2
- **Why this stays active**: Review packs, evidence snapshots, stored reports, customer review foundations, and accepted-risk workflow are repo-real. The missing gap is repeatable MSP/customer-safe packaging, not raw reporting substrate.
- **Roadmap relationship**: MSP sellability / recurring governance service.
- **Dependencies**:
- customer review workspace productization
- compliance evidence mapping
- review packs, evidence snapshots, and stored reports
- findings and accepted-risk workflow
- localization, entitlements, and export maturity
- **Scope**:
- one on-demand management-ready governance package built from the existing review-pack and evidence pipeline
- executive summary with customer-safe language
- top findings, accepted risks, open decisions, and evidence links
- bounded MSP branding and packaging rules
- no scheduling, batching, or report-program engine in the first slice
- **Non-scope**:
- CRM, newsletter, or marketing automation
- PSA replacement or service-desk workflow
- raw operator-data dumps as the default deliverable
- a separate reporting engine that bypasses existing review/evidence/export truth
- **Acceptance criteria**:
- an MSP can generate one repeatable on-demand governance package from existing review, evidence, and accepted-risk artifacts
- the output is customer-safe and management-readable by default
- top findings, accepted risks, open decisions, and evidence links are clearly represented
- packaging reuses shared review/evidence/export foundations instead of creating a parallel report domain
- bounded branding or presentation options do not weaken auditability or customer-safe defaults
- **Smallest useful v1**: one management-ready package for one review context, generated on demand from existing artifacts. Leave recurring schedules, multi-pack campaigns, and broader customer-communications automation out of scope.
### External Support Desk / PSA Handoff
- **Priority**: P2
- **Why this stays active**: In-app support requests are already repo-real. The remaining gap is external handoff and visible ticket linkage, not support-request creation itself.
@ -292,7 +373,55 @@ ## Deferred / Existing Drafts Outside the Current Queue
These items are still useful, but they are not the next best open specs from the current repo state.
- `Policy Lifecycle / Ghost Policies`: still a valid gap, but not ahead of Customer Review Workspace or Cross-Tenant Compare.
### Workspace, Tenant & Managed Object Lifecycle Governance v1
- **Priority**: P2 — Important hardening / enterprise trust
- **Status**: Strategic candidate, not ready for immediate implementation
- **Do not prep before**: Customer Review Workspace, Cross-Tenant Compare & Promotion, Governance Decision Convergence, and current sellability/productization follow-through are materially closed.
- **Why this replaces `Policy Lifecycle / Ghost Policies`**: A policy-only ghost lifecycle spec risks introducing local deletion semantics, Laravel `SoftDeletes`, or overloaded `ignored_at` behavior before TenantPilot has a clear platform lifecycle taxonomy. The real roadmap-fit problem is broader: TenantPilot needs consistent lifecycle truth for workspaces, tenants, managed provider objects, evidence, backups, restoreability, export, retention, and purge.
- **Problem**: Lifecycle concerns currently appear across separate product areas such as policies, restore flows, commercial state, workspace entitlements, backup history, evidence snapshots, audit, support, and workspace administration. Without one shared taxonomy, local fixes can collapse different meanings into the same field or UI label: provider object missing, local TenantPilot record deleted, operator ignored the item, workspace suspended, data retained for compliance, data eligible for purge, or restore no longer possible.
- **Product goal**: Define an enterprise-grade lifecycle model before implementing destructive or retention-sensitive workflows. TenantPilot must distinguish at least these dimensions:
- **Local record lifecycle**: active, archived, locally removed, purge scheduled, purged
- **Provider presence lifecycle**: present, missing from provider, provider deleted, reappeared
- **Operator suppression lifecycle**: visible, ignored / suppressed, restored to visibility
- **Commercial / workspace lifecycle**: trial, active, grace, suspended read-only, closed
- **Retention / compliance lifecycle**: retained, export requested, deletion requested, deletion scheduled, legal hold / retention hold, purge due, purged
- **Restoreability lifecycle**: restorable, metadata only, blocked by dependency, not restorable, expired by retention
- **Smallest useful v1**: Do not implement deletion flows immediately. First define the lifecycle taxonomy, naming rules, state boundaries, audit expectations, OperationRun expectations, retention boundaries, and implementation guardrails for future specs.
- **Questions v1 must answer**:
- What does “deleted” mean in TenantPilot?
- What does “missing from provider” mean?
- What does “ignored” mean?
- What happens when a tenant is removed from a workspace?
- What happens when a workspace is suspended or closed?
- What data remains visible in read-only or suspended states?
- What data must be exportable before deletion?
- What data is retained for audit, evidence, or legal reasons?
- What can be purged, and what must never be purged automatically?
- Which lifecycle transitions require explicit human confirmation?
- Which transitions require audit events?
- Which transitions require OperationRun truth?
- Which transitions affect restore eligibility?
- **Explicit non-goals for v1**:
- no immediate workspace deletion implementation
- no immediate tenant deletion implementation
- no purge engine
- no hard-delete workflow
- no policy-only ghost lifecycle patch
- no Laravel `SoftDeletes` rollout
- no migration that reinterprets existing `ignored_at` data
- no new lifecycle dashboard or workboard
- no new restore engine
- no payment-provider or billing integration
- **Expected follow-up specs after taxonomy approval**:
1. `Provider-Missing Managed Object Truth v1` — explicit provider-missing state for policies and later other managed objects, no local deletion semantics, restore continuity where backup-backed history exists.
2. `Workspace & Tenant Closure Lifecycle v1` — close workspace, remove tenant from workspace, define read-only / suspended / closed behavior, no destructive purge yet.
3. `Data Export Before Deletion v1` — export customer-owned evidence, reports, audit-relevant artifacts, restore metadata, and tenant/workspace records before deletion.
4. `Retention & Purge Governance v1` — retention periods, legal hold, purge eligibility, irreversible deletion confirmation, and audit trail.
5. `Restoreability Expiry & Evidence Retention v1` — distinguish restorable backup payloads from retained evidence/audit metadata and define when restore is no longer possible but evidence remains retained.
- **Roadmap fit**: This is not a P0 sales feature. It is a P2 enterprise trust and compliance hardening candidate that becomes important before serious production customer offboarding, destructive data operations, or regulated retention commitments. It must not block Customer Review Workspace Productization, Governance Decision Surface Convergence, or Cross-Tenant Compare & Promotion.
- **Candidate decision**: Keep as strategic candidate. Do not implement a narrow Ghost Policy spec until the lifecycle taxonomy is agreed. If provider-missing policy behavior becomes an immediate product bug, create a smaller follow-up spec named `Provider-Missing Policy Visibility & Restore Continuity v1`; that smaller spec must use `provider_deleted_at`, `missing_from_provider_at`, or an equivalent provider-presence field and must not use Laravel `SoftDeletes` or local deletion semantics.
- `Workspace-level PII override for review packs`: bounded deferred follow-up from Spec 109.
- `CSV export for filtered run metadata`: valid system-console follow-up, but not near the top of the queue.
- `Raw error/context drilldowns for system console`: useful operator enhancement, but not ahead of current P0-P2 gaps.
@ -312,6 +441,8 @@ ## Promoted to Spec
- In-App Support Request with Context -> Spec 246 (`support-request-context`)
- Plans, Entitlements & Billing Readiness -> Spec 247 (`plans-entitlements-billing-readiness`)
- Private AI Execution & Policy Foundation -> Spec 248 (`private-ai-policy-foundation`)
- Customer Review Workspace v1 -> Spec 249 (`customer-review-workspace`)
- Decision-Based Governance Inbox v1 -> Spec 250 (`decision-governance-inbox`)
- Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`)
- Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`)
- Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`)
@ -353,4 +484,5 @@ ## Superseded / Removed From Active Queue
- `In-App Support Request with Context`: remove from active candidates because it is already Spec 246 and repo-implemented.
- `Plans, Entitlements & Billing Readiness`: remove as a broad active candidate because Spec 247 already exists and the remaining open gap is narrower commercial lifecycle maturity.
- `Private AI Execution & Policy Foundation`: remove from the active queue because Spec 248 already exists.
- `Localization v1`: remove as a broad active candidate because the locale foundation is already repo-real; the remaining work is surface adoption, copy/glossary completion, and customer-facing polish inside narrower productization or UI-maturity follow-ups.
- Company-ops items such as `Lead Capture & CRM Pipeline`, `AVV / DPA / TOM / Legal Pack`, `Vendor Questionnaire Answer Bank`, `Business Continuity / Founder Backup Plan`, and similar operating artifacts should remain outside the active product-spec queue unless a concrete product slice emerges.

View File

@ -1,5 +1,10 @@
# Admin Canonical Tenant Rollout
> **Status:** Historical
> **Last reviewed:** 2026-04-30
> **Use for:** Historical rollout notes for the admin canonical tenant transition
> **Do not use for:** Current implementation truth without checking the corresponding specs and code
## Purpose
Spec 136 completes the workspace-admin canonical tenant rule across admin-visible and admin-reachable shared surfaces. Workspace-admin requests under `/admin/...` resolve tenant context through `App\Support\OperateHub\OperateHubShell::activeEntitledTenant(request())`. Tenant-panel requests under `/admin/t/{tenant}/...` keep panel-native tenant semantics.

View File

@ -1,5 +1,10 @@
# Canonical Tenant Context Resolution
> **Status:** Historical
> **Last reviewed:** 2026-04-30
> **Use for:** Historical context for the canonical tenant resolution rule and exception model
> **Do not use for:** Current path truth or current panel behavior without repo verification
## Canonical Rule
- Tenant-panel and tenant-scoped flows keep panel-native tenant semantics through `Filament::getTenant()` / `Tenant::current()`.

View File

@ -1,5 +1,10 @@
# SECTION A — FILAMENT V5 NOTES (BULLETS + SOURCES)
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Filament v5 reference notes and framework-specific decision checks when local behavior is uncertain
> **Do not use for:** Assuming local implementation already follows every note without repo verification
## Versioning & Base Requirements
- Rule: Filament v5 requires Livewire v4.0+.
When: Always when installing/upgrading Filament v5. When NOT: Never target Livewire v3 in a v5 codebase.

View File

@ -1,5 +1,10 @@
# Golden Master / Baseline Drift — Deep Settings-Drift (Content-Fidelity) Analysis
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Deep drift-engine research, architectural rationale, and fidelity trade-off analysis
> **Do not use for:** Current implementation truth or roadmap priority without repo verification
>
> Enterprise Research Report for TenantAtlas / TenantPilot
> Date: 2025-07-15
> Scope: Architecture, code evidence, implementation proposal

View File

@ -1,5 +1,10 @@
# TenantPilot M365 Policy Coverage Gap Analysis
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Coverage expansion planning and M365 policy gap research
> **Do not use for:** Current productization priority order without roadmap review
**Date:** 2026-03-07
**Author:** Gap Analysis (Automated Deep Research)
**Scope:** Security, Governance & Baseline-relevante Policy-Familien über Microsoft 365 hinweg

View File

@ -1,5 +1,10 @@
# Redaction / Masking / Sanitizing — Codebase Audit Report
> **Status:** Needs Review
> **Last reviewed:** 2026-04-30
> **Use for:** Security and data-integrity audit findings around redaction behavior and masking risks
> **Do not use for:** Assuming every finding is still open without verifying the current codebase
**Auditor:** Security + Data-Integrity Codebase Auditor
**Date:** 2026-03-06
**Scope:** Entire TenantAtlas repo (excluding `vendor/`, `node_modules/`, compiled views)

View File

@ -1,5 +1,10 @@
# Domain Coverage Map
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Domain classification, planning boundaries, and evaluating which Microsoft domains fit which TenantPilot product primitives
> **Do not use for:** Current release priority or implementation truth without roadmap, spec, and code verification
>
> Canonical classification of Microsoft domains for TenantPilot platform planning.
> This document defines which domains receive which product primitives and why.

View File

@ -0,0 +1,27 @@
# TenantPilot Product Vision
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Long-term product direction and roadmap alignment
> **Do not use for:** Implementation truth without repo verification
TenantPilot is a Governance-of-Record platform for Microsoft tenant governance, evidence-first reviews, MSP portfolio operations, Intune backup/restore, auditability, customer-safe review consumption, and decision-based governance workflows.
TenantPilot is not a generic Microsoft admin mirror.
## Core Principles
- OperationRun Truth Layer
- Evidence-first Reporting
- Customer-safe Review Consumption
- Decision-first, diagnostics-second, evidence-third UX
- Capability-first RBAC
- Workspace-first Multi-Tenancy
- Provider-extensible Architecture
- Auditability
- MSP Portfolio Governance
- Enterprise SaaS UX over admin-tool sprawl
## Strategic Direction
The platform should prioritize productization of existing foundations before adding isolated technical coverage. New coverage should strengthen reviews, evidence, findings, baselines, governance inbox, or MSP portfolio workflows.

View File

@ -1,5 +1,10 @@
# Website Working Contract
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Guardrails around keeping `apps/website` an intentionally independent track
> **Do not use for:** Introducing new runtime coupling without explicit contract changes and repo verification
>
> Guardrails for evolving `apps/website` as an independently evolvable track in the current repository.
> This document is repo-truth-based and describes the currently verified state, not a speculative future architecture.

View File

@ -1,5 +1,10 @@
# Action surface contract
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Action placement and affordance rules for Filament resources, pages, and relation managers
> **Do not use for:** Skipping authorization, confirmation, or resource-specific UX review
This project enforces a small “action surface contract” for Filament Resources / Pages / RelationManagers to keep table UIs consistent, quiet, and safe.
## Inspect affordance (required)

View File

@ -1,5 +1,10 @@
# Filament Table Standard
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Standard list-surface rules for production Filament tables
> **Do not use for:** Overriding product-specific needs without an explicit documented exception
## Standard
TenantPilot standardizes production Filament list surfaces with a convention-first model:

View File

@ -1,5 +1,10 @@
# Operator UX & Surface Standards
> **Status:** Active
> **Last reviewed:** 2026-04-30
> **Use for:** Audience, language, disclosure, and operator-surface rules for product UX
> **Do not use for:** Treating internal implementation structure as product IA or redefining constitution terms locally
This document defines the binding audience-and-surface contract for TenantPilot.
It establishes:

View File

@ -1,5 +1,10 @@
# Shared Diff Presentation Foundation
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Reusable presentation rules for simple before/after comparison surfaces
> **Do not use for:** Domain diff logic, data fetching, or token-level compare behavior
## Purpose
Use the shared diff presentation foundation when a screen already has simple before/after data and needs:

View File

@ -1,5 +1,10 @@
# Feature Specification: TenantPilot v1
> **Status:** Historical
> **Last reviewed:** 2026-04-30
> **Use for:** Early product-history context and original v1 framing
> **Do not use for:** Current implementation truth, current roadmap priority, or current spec structure without repo verification
**Feature Branch**: `tenantpilot-v1`
**Created**: 2025-12-10
**Status**: Draft

View File

@ -1,5 +1,10 @@
# Feature 003: Implementation Status Report
> **Status:** Needs Review
> **Last reviewed:** 2026-04-30
> **Use for:** Historical implementation-progress context for Spec 003
> **Do not use for:** Proof that the remaining manual verification or tests were completed in the current repo state
## Executive Summary
**Status**: ✅ **Core Implementation Complete** (Phases 1-5)

View File

@ -1,5 +1,10 @@
# Feature 003: Manual Testing Checklist
> **Status:** Reference
> **Last reviewed:** 2026-04-30
> **Use for:** Manual verification scenarios for Spec 003 if that surface needs targeted re-checks
> **Do not use for:** Proof that manual testing was executed or passed in the current branch
## Prerequisites
1. **Start the application:**

View File

@ -55,3 +55,5 @@ ## 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.
- 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.
- 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
**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)
## Summary
@ -9,6 +9,29 @@ ## 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.
## 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
**Language/Version**: PHP 8.4, Laravel 12
@ -24,7 +47,7 @@ ## Technical Context
## 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
- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy
- **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
- **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
- **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
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament

View File

@ -2,10 +2,20 @@ # Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
**Created**: 2026-01-07
**Updated**: 2026-04-27
**Status**: Ready for implementation
**Updated**: 2026-04-30
**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.
## 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)*
- **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 |
|---|---|---|---|---|---|---|---|
| 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)*
@ -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 |
|---|---|---|---|---|---|---|---|---|---|---|
| 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)*
@ -242,7 +252,7 @@ ### User Story 3 - Launch compare from portfolio context without losing return s
**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.
### Edge Cases

View File

@ -17,20 +17,20 @@ # Tasks: Cross-Tenant Compare Preview and Promotion Preflight
## Test Governance Checklist
- [ ] 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.
- [ ] 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.
- [ ] 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] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only.
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history.
- [x] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope.
- [x] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces.
- [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)
**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.
- [ ] 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] 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] 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] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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] 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] 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] 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] 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] 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.
@ -58,15 +58,15 @@ ## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP
### 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`.
- [ ] 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] 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] 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] 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
- [ ] 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.
- [ ] 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] 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] 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] 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.
@ -80,15 +80,15 @@ ## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P
### 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`.
- [ ] 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] 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] 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] 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
- [ ] 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.
- [ ] 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] 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] 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] 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.
@ -102,13 +102,13 @@ ## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing
### 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`.
- [ ] 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] 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] 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
- [ ] 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`.
- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format.
- [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.
- [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.
@ -118,11 +118,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns
**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`.
- [ ] 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`.
- [ ] 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] 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] 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] 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] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource.
- [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,48 @@
# Specification Quality Checklist: Enforce Creation-Time Finding Invariants
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-29
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Repo-specific classes, routes, file paths, and validation commands appear only where they are required to keep the three active writer families and proof obligations unambiguous
- [x] Focused on user value and business needs
- [x] Written for product and review stakeholders, with repo-grounded detail only where the bounded invariant target would otherwise stay ambiguous
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria stay outcome-oriented even though the package names concrete writer families and proof files needed to bound the slice
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No unbounded implementation plan leaks into the specification; repo-specific commands and paths stay limited to selection, dependency, and validation context
## Test Governance Review
- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, with the three writer suites as the primary proof and only bounded recurrence, consumer, and trigger-authorization regressions where FR-255-005, FR-255-006, FR-255-009, and FR-255-011 require them.
- [x] No new browser or heavy-governance family is introduced; adjacent proof remains inside existing feature suites only.
- [x] Suite-cost outcome stays bounded and reviewable: the package reuses existing writer, recurrence, consumer, and auth suites without adding a new default-heavy harness.
## Review Outcome
- [x] Review outcome class: `acceptable-special-case`
- [x] Workflow outcome: `keep`
- [x] Review-note location is explicit: guardrail, lane-fit, and bounded-proof notes live in `spec.md`, `plan.md`, `tasks.md`, and this checklist.
## Notes
- Repo-surface names, validation commands, and current writer/test anchors are intentionally present because this prep package must distinguish the three active finding writers from already-completed adjacent cleanup specs.
- The spec remains behavior-first: write-time lifecycle readiness, recurrence identity, reopen truth, and unchanged RBAC/tenant isolation are the product outcomes; repo details only keep the package reviewable and bounded.
- No blocking open question remains for safe planning.

View File

@ -0,0 +1,101 @@
version: 1
kind: finding-creation-invariants
scope:
goal: enforce lifecycle-ready finding creation and recurrence or reopen semantics across the active finding writers only
non_goals:
- repair tooling or backfill runtime surfaces
- new workflow states or new findings lifecycle families
- customer-facing workflow expansion
- compare refresh work
- external support handoff
- broader findings redesign
- silent database-constraint rollout
stop_conditions:
- another shipped finding writer is discovered outside the three confirmed paths
- application-level write enforcement proves insufficient without a migration or DB constraint
- the only available implementation shape is a new generic invariant framework
active_writer_families:
baseline_compare:
owner_files:
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
identity:
canonical_key: recurrence_key
fingerprint_contract: fingerprint equals recurrence_key
observation_boundary:
duplicate_guard: current_operation_run_id prevents double counting the same compare run
entra_admin_roles:
owner_files:
- apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
identity:
canonical_key: existing role-assignment or aggregate fingerprint
observation_boundary:
duplicate_guard: later observedAt advances seen history
permission_posture:
owner_files:
- apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
identity:
canonical_key: existing permission or error fingerprint
observation_boundary:
duplicate_guard: later observedAt advances seen history
shared_lifecycle_contract:
model:
owner_file: apps/platform/app/Models/Finding.php
invariants:
- workspace_id and tenant_id remain required ownership anchors
- no new status or reason-code family is introduced
reopen_service:
owner_file: apps/platform/app/Services/Findings/FindingWorkflowService.php
requirement:
- terminal findings reopen only through reopenBySystem
- reopened_at is set
- resolved and closed markers clear according to current service behavior
- sla_days and due_at are recalculated from reopenedAt
- existing audit and alert side effects are preserved
lifecycle_invariants:
create:
required_fields:
- status is new
- first_seen_at equals observedAt
- last_seen_at equals observedAt
- times_seen equals 1
- sla_days is initialized when the current severity policy returns a value
- due_at is initialized when the current severity policy requires due-state truth
contextual_fields:
- current_operation_run_id remains populated where the current writer already sets it
refresh_existing:
required_behavior:
- the same canonical finding identity is reused
- missing first_seen_at, last_seen_at, and times_seen are repaired inline
- missing sla_days or due_at covered by this slice are repaired inline without a second-pass repair tool
- already-valid lifecycle fields are not reset unnecessarily
reopen:
required_behavior:
- the same canonical finding identity is reopened, not duplicated
- resolved_at and resolved_reason clear on reopen
- first_seen_at is retained
- last_seen_at and times_seen advance according to the family observation rule
downstream_regression_consumers:
findings_surfaces:
owner_files:
- apps/platform/app/Filament/Resources/FindingResource.php
- apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
- apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
- apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php
expectation:
- no design change is required; these surfaces should continue to read truthful due_at and reopened_at data from the same Finding records
validation_expectations:
required_feature_proof:
- baseline compare proves create readiness, same-run retry protection, reopened reuse, and inline repair of incomplete lifecycle fields
- Entra admin roles proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields
- permission posture proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields
excluded_lanes:
- browser
- heavy-governance
migration_posture:
- no new migration or schema artifact is allowed in this slice

View File

@ -0,0 +1,130 @@
# Data Model — Enforce Creation-Time Finding Invariants
**Spec**: [spec.md](spec.md)
This feature introduces no new persisted truth. The data-model impact is to make the existing `Finding` lifecycle contract explicit at create, refresh, and reopen time across the three active writer families.
## Existing Canonical Entities Reused
### Finding (`findings`)
**Purpose**: Tenant-owned findings workflow truth.
**Key fields already in use**:
- `id`
- `workspace_id`
- `tenant_id`
- `finding_type`
- `source`
- `scope_key`
- `fingerprint`
- `recurrence_key`
- `severity`
- `status`
- `first_seen_at`
- `last_seen_at`
- `times_seen`
- `sla_days`
- `due_at`
- `reopened_at`
- `resolved_at`
- `resolved_reason`
- `closed_at`
- `closed_reason`
- `current_operation_run_id`
- `baseline_operation_run_id`
**Feature use**:
- Remains the single persisted source of truth for active findings lifecycle state.
- Continues to require both `workspace_id` and `tenant_id` anchors.
- Keeps the current status families unchanged.
- Carries the lifecycle-ready fields that this feature hardens at write time.
### OperationRun (`operation_runs`)
**Purpose**: Existing execution context for baseline compare and other operational flows.
**Feature use**:
- Remains contextual only.
- `current_operation_run_id` continues to identify the current writer run where the family already sets it.
- No new operation type or new run-tracking artifact is introduced.
### StoredReport (`stored_reports`)
**Purpose**: Existing stored reporting artifact for permission posture output.
**Feature use**:
- Unchanged.
- Mentioned only because permission posture finding generation already correlates lifecycle-ready findings with an existing report artifact.
## Derived Non-Persisted Contracts
### LifecycleReadyFinding (derived contract)
**Definition**: A `Finding` record that is immediately usable by the existing workflow the moment the active writer persists or refreshes it.
**Required fields**:
- active canonical status on first create (`new`)
- `first_seen_at`
- `last_seen_at`
- `times_seen >= 1`
- `sla_days` when the current severity policy returns a value
- `due_at` when the current severity policy requires due-date truth
- existing run correlation fields preserved where the writer already populates them
**Removal rule**:
- no later repair surface may be required for these fields on active writers
### RecurrenceIdentity (derived contract)
**Definition**: The family-owned identity that decides whether a repeated observation refreshes one canonical finding or incorrectly creates a duplicate.
**Family-specific variants**:
- baseline compare: `recurrence_key` and `fingerprint` derived from tenant, baseline profile, policy type, subject key, and change type
- Entra admin roles: existing role-assignment and aggregate fingerprints
- permission posture: existing permission and error fingerprints
**Guarantee**:
- repeated observation of the same canonical issue reuses one finding identity
### ObservationBoundary (derived contract)
**Definition**: The family-specific rule that decides whether `times_seen` should advance.
**Family-specific variants**:
- baseline compare: same `current_operation_run_id` must not increment `times_seen` twice for the same observation
- Entra admin roles: later `observedAt` advances seen history
- permission posture: later `observedAt` advances seen history
**Guarantee**:
- retries and repeated processing do not double count the same observation
## State Transitions Reused
### Create
- missing canonical finding identity -> create one `Finding`
- resulting state remains `new`
- lifecycle-ready fields are populated in the same write path
### Refresh Existing Open Finding
- existing open finding remains in its current active workflow state
- evidence or severity may refresh according to the writer family
- missing lifecycle-ready fields covered by this feature are repaired inline
- valid existing lifecycle fields should not be needlessly reset
### Reopen Existing Terminal Finding
- existing terminal finding transitions through `FindingWorkflowService::reopenBySystem()`
- resulting state becomes `reopened`
- `resolved_*` and `closed_*` markers clear according to the current service behavior
- SLA and due-state truth are recalculated from the later re-observation moment
## Invariant Rules
- No new persisted entity, table, or compatibility artifact may be introduced.
- No new workflow status, reopen reason family, or lifecycle label may be introduced.
- Active writers must repair incomplete lifecycle-ready fields inline rather than relying on CLI repair commands, tenant maintenance actions, or deploy-time hooks.
- Due-state repair should fill missing truth or refresh terminal-to-reopened truth only; it must not silently redesign current due-date semantics for already-healthy open findings.
- A later database constraint is a separate follow-up candidate only if application-level write-path enforcement proves insufficient.

View File

@ -0,0 +1,295 @@
# Implementation Plan: Enforce Creation-Time Finding Invariants
**Branch**: `255-enforce-finding-creation-invariants` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md`
**Note**: This plan is prep-only. It updates only spec-package artifacts for implementation readiness and does not change application code, runtime behavior, migrations, assets, or repo files outside this spec directory.
## Summary
- Make lifecycle-ready finding creation and recurrence semantics explicit across the only three active finding writers currently persisting `Finding` records: baseline compare drift, Entra admin roles, and permission posture.
- Keep the slice narrow and repo-grounded: reuse existing `Finding` fields, existing recurrence identities, existing `FindingWorkflowService::reopenBySystem()`, and existing `FindingSlaPolicy` behavior; do not add repair tooling, workflow states, migrations, or a broader findings framework.
- Tighten validation where repo proof is already strongest: extend the three focused feature suites so they explicitly cover new creation, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active write paths.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `FindingWorkflowService`, `FindingSlaPolicy`, baseline compare job, Entra admin roles finding generator, and permission posture finding generator
**Storage**: PostgreSQL existing `findings`, `operation_runs`, `stored_reports`, and `audit_logs` only; no new persistence or migration is planned
**Testing**: Pest feature tests in the existing generator and compare suites
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and existing background jobs or services that already generate findings
**Project Type**: web
**Performance Goals**: lifecycle invariants must be satisfied in the same write path that creates or refreshes the finding; no second-pass repair job, no extra operator step, and no widened query surface should be required
**Constraints**: LEAN-001 replacement over compatibility shims; no new persistence; no new workflow states; no compare refresh or repair-tooling scope; preserve existing `404` vs `403` behavior; no new Filament assets, panel work, or provider registration changes
**Scale/Scope**: 3 active finding writer families, 1 shared workflow service, 1 shared SLA policy, 1 existing `Finding` model, and 3 established feature-test families plus downstream findings surfaces as regression consumers only
## Likely Affected Repo Surfaces
- Active write paths and their local recurrence or observation logic:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
- Shared lifecycle and due-date seams already reused by those paths:
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- `apps/platform/app/Services/Findings/FindingSlaPolicy.php`
- `apps/platform/app/Models/Finding.php`
- Downstream operator-facing regression consumers that should not need design changes but do rely on `due_at`, `reopened_at`, and canonical open-status truth:
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
- Current focused proof surfaces that already cover part of the invariant and should remain the primary validation entry points:
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
## Domain / Model Fit
- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, table, compatibility projection, or lifecycle wrapper is introduced.
- The slice does not change the canonical findings status set. `new`, `triaged`, `in_progress`, and `reopened` remain the active statuses; `resolved`, `closed`, and `risk_accepted` remain terminal statuses.
- Lifecycle-ready creation in this feature means that the first persisted or inline-repaired record is already safe for existing downstream workflow use: canonical active status, `first_seen_at`, `last_seen_at`, `times_seen >= 1`, and existing SLA or `due_at` truth when the current severity policy requires them.
- Recurrence identity stays family-owned and explicit rather than being normalized into a new shared engine:
- baseline compare uses `recurrence_key` plus `fingerprint`, with `current_operation_run_id` preventing double counting for the same compare run
- Entra admin roles uses its existing role-assignment and aggregate fingerprints
- permission posture uses its existing missing-permission and error fingerprints
- `OperationRun` and `StoredReport` remain contextual references only where current writers already use them. This slice does not introduce a new audit artifact or independent lifecycle store.
## UI / Filament & Livewire Fit
- No operator-facing surface change is planned. Existing findings resource, inbox, and intake surfaces are regression consumers of better write-time truth, not redesign targets.
- Filament remains v5 on Livewire v4.0+; no Livewire v3 behavior or API is in scope.
- `FindingResource` already has a view page, so the hard global-search rule remains satisfied without new work. No new globally searchable resource is added.
- No destructive action is introduced or changed. Any touched findings action surface must keep current server-side authorization and existing `->requiresConfirmation()` behavior where destructive-like actions already exist.
- No panel/provider work is planned. If provider registration ever became relevant later, Laravel 12 and Filament v5 still require panel providers under `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`.
- No asset change is planned. Deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets.
## RBAC / Policy Fit
- This slice should not add a new capability, new role mapping, or new policy branch. User-triggered actions that lead to in-scope finding writes keep their current authorization semantics.
- Tenant membership and workspace membership remain the isolation boundary: non-members stay `404`, in-scope members missing the current capability stay `403`, and no new write bypass is introduced for background or queued generation.
- If implementation appears to require a new capability or policy relaxation just to enforce lifecycle invariants, that is a stop condition and should be split rather than absorbed.
## Audit / Logging Fit
- `FindingWorkflowService::reopenBySystem()` remains the authoritative reopen path because it already owns reopened state mutation, audit context, and alert notification dispatch.
- No new `AuditActionId`, no new operation type, and no new completion notification path should be introduced.
- The feature should preserve existing `current_operation_run_id` and `StoredReport` correlation meaning where current writers already set them. Creation-time hardening must not create a second audit or run-tracking dialect.
## Data / Migration / Constraint Fit
- No migration, no historical data backfill, no deploy hook, and no repair command are planned.
- Under LEAN-001, stale local data or incomplete fixtures should be handled by fixture replacement or inline repair on active write paths, not by compatibility shims.
- A database-level constraint discussion is allowed only as an explicit follow-up or stop condition if planning or implementation proves that application-level write-path enforcement cannot satisfy the invariant safely. It must not be silently folded into this slice.
- If due-date initialization for already-open findings would require recomputing correct existing data instead of filling missing lifecycle fields only, stop and split rather than broadening this feature into a data repair rollout.
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change
- **Native vs custom classification summary**: N/A - existing native Filament findings surfaces remain regression consumers only
- **Shared-family relevance**: none; no new notification, header action, dashboard, or evidence viewer family is added
- **State layers in scope**: none
- **Audience modes in scope**: N/A
- **Decision/diagnostic/raw hierarchy plan**: N/A
- **Raw/support gating plan**: N/A
- **One-primary-action / duplicate-truth control**: existing findings workflow actions remain unchanged; tighter write-time truth prevents partial lifecycle data from competing with the existing canonical action flow
- **Handling modes by drift class or surface**: N/A
- **Repository-signal treatment**: review-mandatory for downstream regression only
- **Special surface test profiles**: standard-native-filament regression only
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: no
- **Systems touched**: N/A for shared operator interaction families; domain reuse stays within existing findings lifecycle services only
- **Shared abstractions reused**: existing `FindingWorkflowService` and `FindingSlaPolicy` only
- **New abstraction introduced? why?**: none by default; if a shared write-time normalizer is later proposed, it must be a narrow findings-domain replacement for duplicated inline repair across all three concrete writers, not a new registry or framework
- **Why the existing abstraction was sufficient or insufficient**: `reopenBySystem()` is already sufficient for terminal-to-reopened transitions. The current planning gap is open-record lifecycle repair, which is still duplicated and partially covered across the three writers.
- **Bounded deviation / spread control**: none; keep any repair logic either local to each writer or in one bounded findings-domain helper only if it replaces real duplication immediately
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: N/A
- **Delegated UX behaviors**: N/A
- **Surface-owned behavior kept local**: existing baseline compare and other generation flows keep their current start and completion UX unchanged
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: existing tenant-owned findings truth only
- **Neutral platform terms / contracts preserved**: existing `Finding` lifecycle and tenant/workspace ownership vocabulary remain unchanged
- **Retained provider-specific semantics and why**: provider-specific recurrence evidence stays inside the existing writer families that already own it
- **Bounded extraction or follow-up path**: N/A
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- LEAN-001: PASS - the slice is explicitly app-code hardening only; no compatibility shim, legacy alias, fallback reader, or migration path is planned.
- TEST-GOV-001: PASS - proof stays in the narrowest existing feature suites, with no browser lane and no new heavy-governance family.
- RBAC-UX: PASS - no new capability or policy branch is introduced; non-members remain `404`, members lacking the current capability remain `403`, and system generation stays tenant-scoped.
- PERSIST-001: PASS - no new persisted truth, table, artifact, or projection is introduced.
- STATE-001: PASS - no new state, reason-code family, or lifecycle branch is added; current findings states remain authoritative.
- PROP-001 / ABSTR-001: PASS - the narrowest plan is to align the three concrete write paths and reuse the existing reopen service. Any helper beyond that is a stop-and-justify decision, not a default.
- XCUT-001 / UI-SEM-001: PASS - no new operator interaction family or presentation framework is introduced.
- Filament v5 / Livewire v4 compliance: PASS - existing findings surfaces stay on native Filament v5 with Livewire v4.0+; no legacy API mixing is planned.
- Global-search hard rule: PASS - `FindingResource` already has a view page, and no new searchable resource is added.
- Panel/provider registration: PASS - no panel/provider work is planned; if needed later, Filament v5 on Laravel 12 still uses `apps/platform/bootstrap/providers.php`.
- Destructive confirmation standard: PASS - no new destructive action is added; existing destructive-like actions remain outside this slice.
- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged.
- Auditability and tenant isolation: PASS - reopen semantics remain on the current audited service path, and every in-scope write remains bound to tenant and workspace context.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for writer-level creation-time lifecycle readiness, shared recurrence/workflow-service behavior, and narrow downstream consumer plus trigger-authorization continuity checks; no new unit, browser, or heavy-governance family is planned
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the feature risk lives in domain write behavior already exercised through the existing compare and generator suites, but FR-255-005, FR-255-006, FR-255-009, and FR-255-011 also require bounded proof of shared recurrence/workflow behavior and unchanged consumer/auth continuity. Focused feature coverage is still sufficient because the adjacent checks stay limited to existing findings and trigger-authorization suites.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- **Fixture / helper / factory / seed / context cost risks**: low to moderate but bounded; reuse existing tenant, operation-run, snapshot, generator, and trigger-surface fixtures. Avoid a new umbrella findings harness unless repeated setup clearly becomes the bottleneck.
- **Expensive defaults or shared helper growth introduced?**: no; the plan explicitly avoids a new generic invariant framework or new default-heavy helper layer.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief; no browser smoke is required because no operator-facing interaction changes are planned
- **Closing validation and reviewer handoff**: rerun the three writer suites plus the bounded recurrence/workflow and consumer/auth suites, confirm each family now proves missing-field inline repair in addition to existing create/idempotence/reopen behavior, and verify that no migration, no policy branch, and no new UI action was introduced while hardening write paths.
- **Budget / baseline / trend follow-up**: none expected beyond routine feature-test maintenance
- **Review-stop questions**: did implementation widen into a repair tool, migration, DB constraint rollout, or generic invariant framework; did it silently reset already-valid due dates; did it leave one writer family with only partial invariant proof
- **Escalation path**: reject-or-split
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: routine lifecycle-hardening proof belongs in this feature unless a database-level constraint or a broader findings lifecycle redesign is proven necessary later
## Rollout & Risk Controls
- Rollout is code-only and bounded. No migration, queue worker sequencing, asset build, or provider registration step is expected.
- Recommended implementation order is:
1. confirm the shared invariant vocabulary and stop conditions against the three active writers only
2. harden baseline compare first because it already carries the strictest observation-boundary rule through `current_operation_run_id`
3. align permission posture and Entra admin roles creation and refresh logic around the same lifecycle-ready contract while preserving their family-specific recurrence rules
4. extract a shared normalizer only if the concrete code shows immediate duplication across all three paths and the helper replaces duplication instead of adding a new abstraction layer
5. extend focused regression tests and verify downstream findings surfaces do not require design changes
- Stop conditions for task execution:
- another shipped finding writer is discovered outside the three confirmed paths
- the invariant cannot be enforced safely without a migration or DB constraint
- the only available code shape is a new generic registry, strategy system, or lifecycle framework
- user-facing findings workflow affordances would need to change to compensate for missing write-time truth
## Project Structure
### Documentation (this feature)
```text
specs/255-enforce-finding-creation-invariants/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── checklists/
│ └── requirements.md
├── contracts/
│ └── finding-creation-invariants.contract.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Jobs/
│ │ └── CompareBaselineToTenantJob.php
│ ├── Models/
│ │ └── Finding.php
│ ├── Services/
│ │ ├── EntraAdminRoles/
│ │ │ └── EntraAdminRolesFindingGenerator.php
│ │ ├── Findings/
│ │ │ ├── FindingSlaPolicy.php
│ │ │ └── FindingWorkflowService.php
│ │ └── PermissionPosture/
│ │ └── PermissionPostureFindingGenerator.php
│ └── Filament/
│ ├── Pages/Findings/
│ │ ├── FindingsIntakeQueue.php
│ │ └── MyFindingsInbox.php
│ └── Resources/
│ ├── FindingResource.php
│ └── FindingResource/
│ └── Pages/ListFindings.php
└── tests/
└── Feature/
├── Baselines/BaselineCompareFindingsTest.php
├── EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
├── Findings/FindingRecurrenceTest.php
├── Findings/FindingAutomationWorkflowTest.php
├── Findings/FindingWorkflowServiceTest.php
├── Findings/MyWorkInboxTest.php
├── Findings/FindingsIntakeQueueTest.php
├── Rbac/BaselineCompareMatrixAuthorizationTest.php
├── EntraAdminRoles/AdminRolesSummaryWidgetTest.php
└── PermissionPosture/PermissionPostureFindingGeneratorTest.php
```
**Structure Decision**: Laravel monolith. The implementation should stay inside the existing finding writer services and job, the shared findings lifecycle service and model, and the current focused feature suites rather than creating a new namespace or framework.
## Complexity Tracking
No constitution violation is expected. If implementation later proposes a new persistence rule, a new lifecycle framework, or a broad helper layer that serves only speculative future writers, stop and split rather than justifying it inside this slice.
## Proportionality Review
N/A - this feature introduces no new enum, presenter, persisted entity, interface, registry, or taxonomy. Any narrow helper extracted during implementation must replace existing duplicated write-time lifecycle normalization immediately across the three confirmed writers or it should not be introduced.
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/research.md`
Goals:
- confirm that the three already-named write paths are still the full active finding-writer inventory in app code
- confirm where current code already repairs lifecycle fields inline and where `sla_days` or `due_at` normalization is still only implied on create or reopen
- document the narrowest shared seam decision: keep repair logic local per writer unless one bounded findings-domain helper clearly replaces real duplication across all three cases
- record the explicit stop condition for any database-level constraint or migration-based enforcement proposal
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/quickstart.md`
Design focus:
- capture one lifecycle-ready finding contract that all three active writers must satisfy without introducing a new persistence or workflow layer
- keep recurrence identity family-owned while making the create, refresh, and reopen guarantees explicit in one planning contract
- keep downstream Filament findings surfaces, inboxes, and intake queues as regression consumers only; no UI redesign is part of this slice
- document the no-migration, no-constraint-by-default posture and the explicit stop condition for any future constraint follow-up
## Phase 1 — Agent Context Update
- Deferred in this prep-only pass because the user explicitly limited edits to this spec directory.
- If maintainers later want full Spec Kit propagation outside the spec package, run:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created later in `/speckit.tasks`)
- keep the feature bounded to the three confirmed writer paths and the shared reopen service
- align creation-time lifecycle initialization and open-record inline repair in `CompareBaselineToTenantJob`, `EntraAdminRolesFindingGenerator`, and `PermissionPostureFindingGenerator`
- preserve family-specific recurrence and observation-boundary behavior while making it explicit in code and tests
- preserve `FindingWorkflowService::reopenBySystem()` as the only reopened-state mutation path
- extend the three focused feature suites so each family proves creation readiness, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active paths
- verify that no migration, no new capability, no new workflow state, no repair surface, and no operator-facing workflow expansion slipped into the implementation slice
## Constitution Check (Post-Design)
Re-check target: PASS. The post-design shape remains prep-only, introduces no new persistence or state family, keeps Filament on Livewire v4.0+, leaves provider registration unchanged in `apps/platform/bootstrap/providers.php`, keeps global search unchanged through the existing `FindingResource` view page, leaves destructive actions untouched, and keeps the proving burden inside the three existing focused feature suites unless a bounded stop condition forces a split.
- **Ownership cost created**: focused ongoing maintenance in the three writer suites plus bounded shared recurrence/workflow and trigger-authorization regressions; no migration, framework, or new persistence cost is added.
- **Alternative intentionally rejected**: a generic invariant framework, a new repair or backfill path, and any DB-constraint rollout were rejected because the repo currently has three concrete writers and current-release truth only requires tightening those exact paths.
- **Release truth**: current-release truth. This package hardens already-shipped finding writers rather than preparing speculative future families.

View File

@ -0,0 +1,39 @@
# Quickstart — Enforce Creation-Time Finding Invariants
## Prereqs
- Docker running
- Laravel Sail dependencies installed
- Existing compare and generator feature fixtures available
- Existing tenant/workspace helpers available for targeted findings tests
## Run locally after implementation
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
- Use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
- Run the focused validation suites for this slice:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- Format any implementation changes: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
The two additional commands are the only bounded adjacent proof beyond the three writer suites. They cover shared recurrence/workflow semantics plus unchanged downstream consumer and trigger-authorization contracts.
## Manual smoke after implementation
1. Trigger one baseline compare drift finding and confirm the newly created record appears immediately usable on `/admin/t/{tenant}/findings`, including due-state and seen-history cues where current UI already renders them.
2. Trigger one permission posture and one Entra admin roles finding and confirm the first persisted record has the expected lifecycle-ready fields without any maintenance action.
3. Resolve an in-scope finding, re-observe the same issue, and confirm the same finding identity reopens with refreshed due or SLA truth and existing history retained.
4. Re-run the same baseline compare operation identity and confirm `times_seen` does not double count on retry.
5. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new repair surface, capability, or operator-facing workflow branch was introduced.
## Notes
- Filament v5 remains on Livewire v4.0+ in this repo; this feature does not add or redesign an operator-facing Filament surface.
- `FindingResource` already has a view page, so there is no new global-search compliance work.
- No new destructive action is planned; existing destructive-like findings actions stay outside this slice and keep their current confirmation and authorization behavior.
- No panel or provider change is planned; `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location for Filament work in Laravel 12.
- No asset change is expected, so there is no additional `filament:assets` deployment work for this slice.
- This prep package intentionally leaves repo-wide agent-context regeneration outside scope so changes stay inside `specs/255-enforce-finding-creation-invariants/` only.

View File

@ -0,0 +1,126 @@
# Research — Enforce Creation-Time Finding Invariants
**Date**: 2026-04-29
**Spec**: [spec.md](spec.md)
This document records the repo-grounded planning decisions for the creation-time findings hardening slice after Specs 253 and 254. All decisions assume the current pre-production LEAN-001 posture.
## Decision 1 — Scope the feature to the three active finding writers that currently persist `Finding` records
**Decision**: Treat baseline compare drift, Entra admin roles, and permission posture as the full active writer set for this feature.
**Rationale**:
- Repo search shows only five direct `Finding` creation sites in app code: one `new Finding` path in `CompareBaselineToTenantJob` and four `Finding::create()` sites split between Entra admin roles and permission posture.
- No other shipped service or job currently persists `Finding` records directly, so widening the slice would be speculative rather than repo-driven.
- This keeps the hardening aligned with the spec's stated bounded scope and avoids inventing a new writer registry.
**Evidence**:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
**Alternatives considered**:
- Widen to every findings consumer or downstream summary surface.
- Rejected: those surfaces consume findings truth but do not create it.
- Add a speculative "all writers" registry now.
- Rejected: violates ABSTR-001 because three concrete paths are already directly visible.
## Decision 2 — Enforce lifecycle readiness in the same write path, not through a later repair pass
**Decision**: Require each in-scope writer to create or refresh lifecycle-ready findings inside the same code path that persists or updates the record.
**Rationale**:
- Spec 253 removes runtime backfill surfaces and this feature explicitly exists to prevent reintroducing that repair dependency.
- Current code already initializes lifecycle fields on new creates and updates some fields inline on repeated observations; that makes write-path hardening the narrowest correct implementation.
- Downstream findings pages, inboxes, and intake queues already assume findings are ready for immediate use.
**Evidence**:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
**Alternatives considered**:
- Reintroduce a maintenance action or backfill command.
- Rejected: directly conflicts with the cleanup direction from Spec 253.
- Add a deploy-time or queue-time repair hook.
- Rejected: widens scope and hides invariant ownership.
## Decision 3 — Preserve `FindingWorkflowService::reopenBySystem()` as the only shared reopen path
**Decision**: Keep terminal-to-reopened mutation on `FindingWorkflowService::reopenBySystem()` and treat open-record lifecycle normalization as the actual planning gap.
**Rationale**:
- `reopenBySystem()` already validates terminal-status eligibility, recalculates SLA or due state, clears resolved or closed markers, writes audit context, and dispatches the reopened alert notification.
- Bypassing it would create a second reopen dialect and risk inconsistent audit or notification semantics.
- The repo gap is not reopened-state ownership; it is that current open-record repair is still distributed across per-family `observeFinding()` logic and currently emphasizes seen-history more than full lifecycle readiness.
**Evidence**:
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
**Alternatives considered**:
- Reopen findings directly inside each writer.
- Rejected: duplicates side effects and weakens audit consistency.
- Create a new generic lifecycle orchestration framework.
- Rejected: too broad for three known writers.
## Decision 4 — Keep recurrence identity family-owned and preserve each writer's current double-count boundary
**Decision**: Keep the existing recurrence identity and observation boundary per family instead of forcing one synthetic cross-domain algorithm.
**Rationale**:
- Baseline compare already uses `recurrence_key` plus `fingerprint` with `current_operation_run_id` to suppress duplicate `times_seen` increments for the same compare run.
- Entra admin roles and permission posture use later `observedAt` comparisons to advance seen history.
- The operator need is one canonical finding identity per issue family, not one universal recurrence engine.
**Evidence**:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
**Alternatives considered**:
- Normalize all writers onto a single recurrence service.
- Rejected: would add abstraction without current-release need.
- Count every repeated observation the same way across all writers.
- Rejected: risks breaking baseline retry semantics.
## Decision 5 — The current proof gap is inline repair of incomplete lifecycle fields on existing findings
**Decision**: Plan for explicit regression proof that existing open findings with missing lifecycle fields are repaired inline on active paths, especially for `sla_days` and `due_at`.
**Rationale**:
- Existing tests already prove creation readiness, idempotence, and reopen behavior in all three families.
- Repo code also already repairs `first_seen_at`, `last_seen_at`, and `times_seen` inline when existing findings are re-observed.
- What is not yet clearly owned as one invariant is the repair of incomplete lifecycle fields such as missing due-state data on existing findings encountered through active writers.
**Evidence**:
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
**Alternatives considered**:
- Rely on current creation and reopen tests only.
- Rejected: leaves FR-255-007 partially implied.
- Add a new browser or broad workflow suite.
- Rejected: too expensive for a write-path invariant gap.
## Decision 6 — Keep schema and DB constraints out of the slice unless they become an explicit stop condition
**Decision**: Keep the default plan app-code-only. Any database-level constraint or migration-based enforcement is a bounded follow-up candidate or an explicit stop condition, not part of this feature by default.
**Rationale**:
- The repo is pre-production and LEAN-001 favors direct replacement over compatibility layers.
- The current code already has the necessary domain seams to harden write-time behavior without changing the schema.
- Folding a constraint into this feature would silently broaden it from write-path hardening into data rollout and compatibility review.
**Evidence**:
- `.specify/memory/constitution.md`
- `specs/255-enforce-finding-creation-invariants/spec.md`
**Alternatives considered**:
- Add `NOT NULL` or check constraints now.
- Rejected: outside the smallest bounded slice.
- Keep the option undefined.
- Rejected: the plan must name the stop condition explicitly so task generation stays bounded.

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