Compare commits
23 Commits
dev
...
256-extern
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2658c3d169 | ||
|
|
fa5858ad33 | ||
| 52ebf63af1 | |||
|
|
230c15f4ae | ||
|
|
2e2b125107 | ||
|
|
4b0dc2a62e | ||
|
|
34351a281d | ||
| 51ea80ca05 | |||
|
|
e36bd3ca9c | ||
| b511b08371 | |||
|
|
f53f149f99 | ||
| 2fa8fc0f87 | |||
|
|
44e6a1eb05 | ||
|
|
4f7c1a6c94 | ||
|
|
4325e1ed8d | ||
|
|
4ae4c2ee95 | ||
|
|
32b6dcb937 | ||
|
|
f7bc4f2787 | ||
|
|
0739018ee5 | ||
|
|
9a02261f5c | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
8
.github/skills/giteaflow/SKILL.md
vendored
8
.github/skills/giteaflow/SKILL.md
vendored
@ -1,8 +0,0 @@
|
||||
---
|
||||
name: giteaflow
|
||||
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||
---
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||
188
.github/skills/platform-feature-finish/SKILL.md
vendored
188
.github/skills/platform-feature-finish/SKILL.md
vendored
@ -1,8 +1,6 @@
|
||||
|
||||
|
||||
---
|
||||
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.
|
||||
description: Commit, push, and create a Gitea pull request from the current TenantPilot platform feature branch into platform-dev.
|
||||
---
|
||||
|
||||
# Skill: platform-feature-finish
|
||||
@ -19,11 +17,6 @@ ## Purpose
|
||||
- "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:
|
||||
|
||||
@ -31,8 +24,7 @@ ## Purpose
|
||||
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
|
||||
5. Report the PR link and next integration step
|
||||
|
||||
---
|
||||
|
||||
@ -66,24 +58,6 @@ ## Branch Model
|
||||
- `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
|
||||
@ -334,95 +308,17 @@ ## Optional Step — Check platform-dev to dev PR
|
||||
|
||||
---
|
||||
|
||||
## Integration Refresh Mode
|
||||
## Full Integration Mode
|
||||
|
||||
Use this mode when the user explicitly says one of the following:
|
||||
Only if 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:
|
||||
Then create or report the integration PR:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -431,29 +327,11 @@ ### Create or Report Integration PR
|
||||
"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."
|
||||
"body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`."
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Do not merge automatically unless explicitly requested.
|
||||
|
||||
---
|
||||
|
||||
@ -478,7 +356,7 @@ ## Reporting Format
|
||||
Tests wurden in diesem Skill nicht automatisch ausgeführt.
|
||||
```
|
||||
|
||||
Do not claim tests passed unless the tool actually ran them.
|
||||
Do not claim tests passed unless they were actually executed.
|
||||
|
||||
---
|
||||
|
||||
@ -486,7 +364,6 @@ ## 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.
|
||||
@ -553,31 +430,6 @@ ## Useful Commands
|
||||
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
|
||||
@ -599,27 +451,3 @@ ## Example User Request
|
||||
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.
|
||||
@ -1,10 +1,5 @@
|
||||
# Research T186 — settings_apply capability verification (LEGACY / DEPRECATED)
|
||||
|
||||
> **Status:** Superseded
|
||||
> **Last reviewed:** 2026-04-30
|
||||
> **Use for:** Historical investigation context only if a later Settings Catalog write-path regression needs provenance
|
||||
> **Do not use for:** Active feature research or current implementation truth
|
||||
|
||||
> DEPRECATED: Do not add new research notes under `.specify/`.
|
||||
> Active feature research should live under `specs/<NNN>-<slug>/`.
|
||||
> Legacy history lives under `spechistory/`.
|
||||
|
||||
@ -1,674 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class CrossTenantComparePage extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
|
||||
|
||||
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
|
||||
|
||||
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $title = 'Cross-Tenant Compare';
|
||||
|
||||
protected static ?string $slug = 'cross-tenant-compare';
|
||||
|
||||
protected string $view = 'filament.pages.cross-tenant-compare';
|
||||
|
||||
public ?string $sourceTenantId = null;
|
||||
|
||||
public ?string $targetTenantId = null;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $selectedPolicyTypes = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $preview = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $preflight = null;
|
||||
|
||||
public ?string $selectionMessage = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
|
||||
$this->hydrateSelectionFromRequest();
|
||||
$this->refreshPreview();
|
||||
|
||||
$this->form->fill($this->formState());
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
'xl' => 3,
|
||||
])
|
||||
->schema([
|
||||
Select::make('sourceTenantId')
|
||||
->label('Source tenant')
|
||||
->options(fn (): array => $this->tenantOptions())
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('Select a source tenant')
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
|
||||
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
|
||||
Select::make('targetTenantId')
|
||||
->label('Target tenant')
|
||||
->options(fn (): array => $this->tenantOptions())
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('Select a target tenant')
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
|
||||
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
|
||||
Select::make('selectedPolicyTypes')
|
||||
->label('Governed subjects')
|
||||
->options(fn (): array => $this->policyTypeOptions())
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('All governed subjects')
|
||||
->helperText(fn (): ?string => $this->policyTypeOptions() === []
|
||||
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
|
||||
: null)
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
|
||||
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_origin')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
|
||||
if ($sourceTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('open_source_tenant')
|
||||
->label('Open source tenant')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('open_target_tenant')
|
||||
->label('Open target tenant')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
|
||||
}
|
||||
|
||||
$preflightAction = Action::make('generatePromotionPreflight')
|
||||
->label('Generate promotion preflight')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||
|
||||
$preflightAction = WorkspaceUiEnforcement::forAction(
|
||||
$preflightAction,
|
||||
fn (): ?Workspace => $this->workspace(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||
->apply()
|
||||
->tooltip(function (): ?string {
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($resolver->isMember($user, $workspace)
|
||||
&& ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to generate a promotion preflight.';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->preflightDisabledReason();
|
||||
});
|
||||
|
||||
$actions[] = $preflightAction;
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function applySelection(): void
|
||||
{
|
||||
$this->selectionMessage = null;
|
||||
$this->preflight = null;
|
||||
|
||||
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
|
||||
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
|
||||
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
|
||||
|
||||
if ($this->sourceTenantId !== null
|
||||
&& $this->targetTenantId !== null
|
||||
&& $this->sourceTenantId === $this->targetTenantId) {
|
||||
$this->selectionMessage = 'Choose two different tenants.';
|
||||
$this->addError('targetTenantId', $this->selectionMessage);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect($this->selectionUrl(), navigate: true);
|
||||
}
|
||||
|
||||
public function generatePromotionPreflight(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->authorizePreflightExecution();
|
||||
|
||||
if ($this->preview === null) {
|
||||
$this->refreshPreview();
|
||||
}
|
||||
|
||||
if ($this->preview === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
|
||||
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
|
||||
if ($workspace instanceof Workspace && $user instanceof User) {
|
||||
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
|
||||
workspace: $workspace,
|
||||
sourceTenant: $selection->sourceTenant,
|
||||
targetTenant: $selection->targetTenant,
|
||||
preflight: $this->preflight,
|
||||
actor: $user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearSelectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters([
|
||||
self::SOURCE_TENANT_QUERY_KEY => null,
|
||||
self::TARGET_TENANT_QUERY_KEY => null,
|
||||
self::POLICY_TYPE_QUERY_KEY => null,
|
||||
]), panel: 'admin');
|
||||
}
|
||||
|
||||
public function selectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters(), panel: 'admin');
|
||||
}
|
||||
|
||||
public static function launchUrl(
|
||||
?Tenant $sourceTenant = null,
|
||||
?Tenant $targetTenant = null,
|
||||
?CanonicalNavigationContext $navigationContext = null,
|
||||
): string {
|
||||
$parameters = [];
|
||||
|
||||
if ($sourceTenant instanceof Tenant) {
|
||||
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
|
||||
}
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
|
||||
}
|
||||
|
||||
if ($navigationContext instanceof CanonicalNavigationContext) {
|
||||
$parameters = array_replace($parameters, $navigationContext->toQuery());
|
||||
}
|
||||
|
||||
return static::getUrl($parameters, panel: 'admin');
|
||||
}
|
||||
|
||||
public function hasActiveSelection(): bool
|
||||
{
|
||||
return $this->sourceTenantId !== null
|
||||
|| $this->targetTenantId !== null
|
||||
|| $this->selectedPolicyTypes !== [];
|
||||
}
|
||||
|
||||
public function stateColor(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'match', 'ready' => 'success',
|
||||
'different', 'manual_mapping_required' => 'warning',
|
||||
'missing' => 'info',
|
||||
'ambiguous' => 'gray',
|
||||
'blocked' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function stateLabel(string $value): string
|
||||
{
|
||||
return Str::headline(str_replace('_', ' ', $value));
|
||||
}
|
||||
|
||||
public function reasonLabel(string $reasonCode): string
|
||||
{
|
||||
return Str::headline(str_replace('_', ' ', $reasonCode));
|
||||
}
|
||||
|
||||
public function sourceTenantUrl(): ?string
|
||||
{
|
||||
$tenant = $this->selectedSourceTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
public function targetTenantUrl(): ?string
|
||||
{
|
||||
$tenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formState(): array
|
||||
{
|
||||
return [
|
||||
'sourceTenantId' => $this->sourceTenantId,
|
||||
'targetTenantId' => $this->targetTenantId,
|
||||
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||
];
|
||||
}
|
||||
|
||||
private function hydrateSelectionFromRequest(): void
|
||||
{
|
||||
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
|
||||
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
|
||||
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
|
||||
}
|
||||
|
||||
private function refreshPreview(): void
|
||||
{
|
||||
$this->selectionMessage = null;
|
||||
$this->preview = null;
|
||||
$this->preflight = null;
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
||||
}
|
||||
|
||||
private function authorizePageAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizePreflightExecution(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compareSelection(): ?CrossTenantCompareSelection
|
||||
{
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
|
||||
$this->selectionMessage = 'Choose two different tenants.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CrossTenantCompareSelection(
|
||||
sourceTenant: $sourceTenant,
|
||||
targetTenant: $targetTenant,
|
||||
policyTypes: $this->selectedPolicyTypes,
|
||||
);
|
||||
}
|
||||
|
||||
private function selectedSourceTenant(): ?Tenant
|
||||
{
|
||||
if ($this->sourceTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAuthorizedTenant($this->sourceTenantId);
|
||||
}
|
||||
|
||||
private function selectedTargetTenant(): ?Tenant
|
||||
{
|
||||
if ($this->targetTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAuthorizedTenant($this->targetTenantId);
|
||||
}
|
||||
|
||||
private function resolveAuthorizedTenant(string $tenantId): Tenant
|
||||
{
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereKey((int) $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantOptions(): array
|
||||
{
|
||||
$workspace = $this->workspace();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->select('tenants.*')
|
||||
->orderBy('tenants.name')
|
||||
->get();
|
||||
|
||||
$resolver->primeMemberships($user, $tenants->modelKeys());
|
||||
|
||||
return $tenants
|
||||
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->mapWithKeys(fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => (string) $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function policyTypeOptions(): array
|
||||
{
|
||||
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return InventoryItem::query()
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->whereNotNull('policy_type')
|
||||
->where('policy_type', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('policy_type')
|
||||
->pluck('policy_type')
|
||||
->mapWithKeys(fn (string $policyType): array => [
|
||||
$policyType => Str::headline($policyType),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function preflightDisabledReason(): ?string
|
||||
{
|
||||
if ($this->selectionMessage !== null) {
|
||||
return $this->selectionMessage;
|
||||
}
|
||||
|
||||
if (! is_array($this->preview)) {
|
||||
return 'Select an authorized source and target tenant to generate a promotion preflight.';
|
||||
}
|
||||
|
||||
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
|
||||
return 'No governed subjects are available for this compare selection yet.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private function normalizeTenantIdentifier(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) && ! is_int($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizePolicyTypes(mixed $value): array
|
||||
{
|
||||
$allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true);
|
||||
|
||||
$values = match (true) {
|
||||
is_string($value) && $value !== '' => [$value],
|
||||
is_array($value) => $value,
|
||||
default => [],
|
||||
};
|
||||
|
||||
return array_values(array_filter(array_unique(array_map(
|
||||
static fn (mixed $item): string => is_string($item) ? trim($item) : '',
|
||||
$values,
|
||||
)), static fn (string $item): bool => $item !== '' && isset($allowed[$item])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function routeParameters(array $overrides = []): array
|
||||
{
|
||||
$parameters = [
|
||||
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
|
||||
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
|
||||
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
|
||||
];
|
||||
|
||||
if (is_array($this->navigationContextPayload)) {
|
||||
$parameters['nav'] = $this->navigationContextPayload;
|
||||
}
|
||||
|
||||
foreach ($overrides as $key => $value) {
|
||||
$parameters[$key] = $value;
|
||||
}
|
||||
|
||||
return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
return $workspace instanceof Workspace ? $workspace : null;
|
||||
}
|
||||
}
|
||||
@ -105,26 +105,14 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
return [
|
||||
Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter());
|
||||
|
||||
return $actions;
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -710,15 +698,6 @@ private function navigationContext(): CanonicalNavigationContext
|
||||
);
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
private function queueUrl(array $overrides = []): string
|
||||
{
|
||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||
|
||||
@ -97,26 +97,14 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
return [
|
||||
Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_tenant_filter')
|
||||
->label('Clear tenant filter')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter());
|
||||
|
||||
return $actions;
|
||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -652,15 +640,6 @@ private function navigationContext(): CanonicalNavigationContext
|
||||
);
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
private function queueUrl(): string
|
||||
{
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
@ -75,8 +75,6 @@ class GovernanceInbox extends Page
|
||||
|
||||
private ?bool $visibleAlertsFamily = null;
|
||||
|
||||
private ?bool $visibleFindingExceptionsFamily = null;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
public ?string $family = null;
|
||||
@ -191,11 +189,12 @@ public function pageUrl(array $overrides = []): string
|
||||
|
||||
public function navigationContext(): CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::forGovernanceInbox(
|
||||
return new CanonicalNavigationContext(
|
||||
sourceSurface: 'governance.inbox',
|
||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||
tenantId: $this->tenantId,
|
||||
backLinkLabel: 'Back to governance inbox',
|
||||
backLinkUrl: $this->pageUrl(),
|
||||
familyKey: $this->family,
|
||||
);
|
||||
}
|
||||
|
||||
@ -224,7 +223,6 @@ private function ensureAtLeastOneVisibleFamily(): void
|
||||
if (
|
||||
$this->hasVisibleOperationsFamily()
|
||||
|| $this->visibleFindingTenants() !== []
|
||||
|| $this->hasVisibleFindingExceptionsFamily()
|
||||
|| $this->reviewTenants() !== []
|
||||
|| $this->hasVisibleAlertsFamily()
|
||||
) {
|
||||
@ -268,27 +266,6 @@ 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>
|
||||
*/
|
||||
@ -398,7 +375,6 @@ private function resolveRequestedFamily(): ?string
|
||||
return in_array($family, [
|
||||
'assigned_findings',
|
||||
'intake_findings',
|
||||
'finding_exceptions',
|
||||
'stale_operations',
|
||||
'alert_delivery_failures',
|
||||
'review_follow_up',
|
||||
@ -448,7 +424,6 @@ 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(),
|
||||
@ -483,7 +458,6 @@ private function unfilteredInboxPayload(): array
|
||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
||||
reviewTenants: $this->reviewTenants(),
|
||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
||||
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
|
||||
selectedTenant: null,
|
||||
selectedFamily: null,
|
||||
navigationContext: $this->navigationContext(),
|
||||
@ -517,4 +491,4 @@ private function tenantFilterAloneExcludesRows(): bool
|
||||
|
||||
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -208,16 +208,6 @@ protected function getHeaderActions(): array
|
||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
||||
);
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($governanceContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
@ -489,10 +479,7 @@ public function selectedExceptionUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
);
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function selectedFindingUrl(): ?string
|
||||
@ -503,10 +490,7 @@ public function selectedFindingUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
);
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function clearSelectedException(): void
|
||||
@ -670,15 +654,6 @@ private function navigationContext(): ?CanonicalNavigationContext
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = $this->navigationContext();
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
private function normalizeSelectedFindingExceptionId(): void
|
||||
{
|
||||
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
||||
@ -808,16 +783,4 @@ private function governanceWarningColor(FindingException $record): string
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,21 +5,16 @@
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -40,7 +35,6 @@
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -50,7 +44,7 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
||||
|
||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
||||
|
||||
public const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
private const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
@ -114,33 +108,20 @@ public function mount(): void
|
||||
$this->authorizePageAccess();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
$this->auditWorkspaceOpen();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$governanceContext = $this->incomingGovernanceContext();
|
||||
|
||||
if ($governanceContext?->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_governance_inbox')
|
||||
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||
->icon('heroicon-o-arrow-left')
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
->icon('heroicon-o-x-mark')
|
||||
->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;
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->clearWorkspaceFilters();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -172,10 +153,6 @@ public function table(Table $table): Table
|
||||
->label(__('localization.review.accepted_risks'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('evidence_proof_state')
|
||||
->label(__('localization.review.evidence_proof'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->evidenceProofAvailability($record))
|
||||
->wrap(),
|
||||
TextColumn::make('published_at')
|
||||
->label(__('localization.review.published'))
|
||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
||||
@ -183,8 +160,7 @@ public function table(Table $table): Table
|
||||
->placeholder('—'),
|
||||
TextColumn::make('review_pack_state')
|
||||
->label(__('localization.review.review_pack'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record))
|
||||
->wrap(),
|
||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
@ -271,32 +247,6 @@ private function authorizePageAccess(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function auditWorkspaceOpen(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::CustomerReviewWorkspaceOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
'entitled_tenant_count' => count($this->authorizedTenants()),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'customer_review_workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
targetLabel: __('localization.review.customer_review_workspace'),
|
||||
);
|
||||
}
|
||||
|
||||
private function workspaceQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -398,13 +348,9 @@ private function latestReviewUrl(Tenant $tenant): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
||||
array_replace(
|
||||
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
),
|
||||
);
|
||||
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
|
||||
self::DETAIL_CONTEXT_QUERY_KEY => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
||||
@ -555,141 +501,30 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||
|
||||
$countSummary = match (true) {
|
||||
return match (true) {
|
||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
||||
};
|
||||
|
||||
$accountability = $this->acceptedRiskAccountability($tenant);
|
||||
|
||||
return $accountability === null
|
||||
? $countSummary
|
||||
: $countSummary.' '.$accountability;
|
||||
}
|
||||
|
||||
private function reviewPackAvailability(Tenant $tenant): string
|
||||
{
|
||||
if (! $this->latestPublishedReview($tenant) instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.no_current_review_pack');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return __('localization.review.review_pack_access_unavailable');
|
||||
return __('localization.review.unavailable');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.review_pack_unavailable');
|
||||
return __('localization.review.unavailable');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.review_pack_expired');
|
||||
return __('localization.review.unavailable');
|
||||
}
|
||||
|
||||
return __('localization.review.review_pack_available');
|
||||
}
|
||||
|
||||
private function evidenceProofAvailability(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return __('localization.review.evidence_proof_absent');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return __('localization.review.evidence_proof_access_unavailable');
|
||||
}
|
||||
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return __('localization.review.evidence_proof_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.evidence_proof_available');
|
||||
}
|
||||
|
||||
private function acceptedRiskAccountability(Tenant $tenant): ?string
|
||||
{
|
||||
$exception = FindingException::query()
|
||||
->with(['owner', 'approver', 'currentDecision'])
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->current()
|
||||
->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end")
|
||||
->latest('approved_at')
|
||||
->latest('requested_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accountable = $exception->owner?->name
|
||||
?? $exception->approver?->name;
|
||||
$decisionType = $exception->currentDecision?->decision_type;
|
||||
$reviewDue = $exception->review_due_at ?? $exception->expires_at;
|
||||
$reason = is_string($exception->request_reason) ? trim($exception->request_reason) : '';
|
||||
$parts = [];
|
||||
|
||||
if (is_string($accountable) && trim($accountable) !== '') {
|
||||
$parts[] = $reviewDue === null
|
||||
? __('localization.review.accepted_risk_accountable', ['name' => $accountable])
|
||||
: __('localization.review.accepted_risk_accountable_until', [
|
||||
'name' => $accountable,
|
||||
'date' => $reviewDue->toDateString(),
|
||||
]);
|
||||
} elseif (is_string($decisionType) && trim($decisionType) !== '') {
|
||||
$parts[] = __('localization.review.accepted_risk_partial_accountability');
|
||||
}
|
||||
|
||||
if ($reason !== '') {
|
||||
$parts[] = __('localization.review.accepted_risk_reason', [
|
||||
'reason' => Str::limit($reason, 160),
|
||||
]);
|
||||
}
|
||||
|
||||
return $parts === [] ? null : implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
|
||||
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
$context = $this->navigationContext();
|
||||
|
||||
return $context?->sourceSurface === 'governance.inbox'
|
||||
? $context
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
return __('localization.review.available');
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,12 +174,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Summary')
|
||||
@ -225,7 +222,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Raw summary JSON')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(4),
|
||||
@ -240,7 +236,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'operation_run',
|
||||
label: 'Operation',
|
||||
@ -259,20 +255,12 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
->first();
|
||||
|
||||
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||
$packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||
|
||||
if (static::isCustomerWorkspaceFlow()) {
|
||||
$packUrl = static::appendQuery($packUrl, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
value: sprintf('#%d', (int) $pack->getKey()),
|
||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||
targetUrl: $packUrl,
|
||||
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||
targetKind: 'direct_record',
|
||||
priority: 20,
|
||||
actionLabel: 'View review pack',
|
||||
@ -297,23 +285,6 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -5,13 +5,8 @@
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
@ -25,13 +20,6 @@ class ViewEvidenceSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$this->auditCustomerWorkspaceProofOpen();
|
||||
}
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||
@ -39,10 +27,6 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||
|
||||
@ -106,41 +90,4 @@ protected function getHeaderActions(): array
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function auditCustomerWorkspaceProofOpen(): void
|
||||
{
|
||||
if (! EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = $this->record;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof EvidenceSnapshot || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'evidence_snapshot_id' => (int) $record->getKey(),
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $record->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $record->getKey()),
|
||||
tenant: $tenant,
|
||||
operationRunId: $record->operation_run_id !== null ? (int) $record->operation_run_id : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,8 +148,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('file_size')
|
||||
->label('File size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -185,7 +184,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
])
|
||||
->columns(2)
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata')
|
||||
@ -229,12 +227,9 @@ public static function infolist(Schema $schema): Schema
|
||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||
})
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
@ -248,7 +243,9 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||
->label('Snapshot completeness')
|
||||
->badge()
|
||||
@ -432,36 +429,6 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
|
||||
{
|
||||
if (! $record->evidenceSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
|
||||
|
||||
return static::isCustomerWorkspaceFlow()
|
||||
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
||||
: $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
@ -19,20 +19,6 @@ class ViewReviewPack extends ViewRecord
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (ReviewPackResource::isCustomerWorkspaceFlow()) {
|
||||
return [
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
|
||||
'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
->openUrlInNewTab(),
|
||||
];
|
||||
}
|
||||
|
||||
$regenerateAction = UiEnforcement::forAction(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantResource\Pages;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||
@ -16,11 +15,9 @@
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
@ -47,7 +44,6 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -72,7 +68,6 @@
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -829,27 +824,6 @@ public static function table(Table $table): Table
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||
Actions\Action::make('compareTenants')
|
||||
->label('Compare tenants')
|
||||
->icon('heroicon-o-scale')
|
||||
->color('gray')
|
||||
->url(function (Tenant $record, mixed $livewire): string {
|
||||
$triageState = $livewire instanceof Pages\ListTenants
|
||||
? static::currentPortfolioTriageState($livewire)
|
||||
: [];
|
||||
|
||||
if (! static::hasActivePortfolioTriageState(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
)) {
|
||||
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||
}
|
||||
|
||||
return static::crossTenantCompareOpenUrl($record, $triageState);
|
||||
})
|
||||
->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
@ -992,34 +966,6 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
Actions\BulkAction::make('compareSelected')
|
||||
->label('Compare selected')
|
||||
->icon('heroicon-o-scale')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => auth()->user() instanceof User)
|
||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||
->extraAttributes(fn (mixed $livewire): array => [
|
||||
'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null',
|
||||
'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire),
|
||||
'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire),
|
||||
'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }',
|
||||
])
|
||||
->action(function (Collection $records, mixed $livewire): void {
|
||||
$disabledReason = static::crossTenantCompareBulkDisabledReason($records);
|
||||
|
||||
if ($disabledReason !== null) {
|
||||
Notification::make()
|
||||
->title($disabledReason)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_exists($livewire, 'redirect')) {
|
||||
$livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true);
|
||||
}
|
||||
}),
|
||||
Actions\BulkAction::make('syncSelected')
|
||||
->label('Sync selected')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -1212,52 +1158,6 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string
|
||||
{
|
||||
return static::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $record,
|
||||
triageState: $triageState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
public static function crossTenantCompareOpenUrlForSelection(
|
||||
Tenant $targetTenant,
|
||||
array $triageState = [],
|
||||
?Tenant $sourceTenant = null,
|
||||
): string {
|
||||
$normalizedState = static::portfolioReturnFilters(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
);
|
||||
|
||||
return CrossTenantComparePage::launchUrl(
|
||||
sourceTenant: $sourceTenant,
|
||||
targetTenant: $targetTenant,
|
||||
navigationContext: CanonicalNavigationContext::forTenantRegistry(
|
||||
backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState),
|
||||
tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
@ -1348,168 +1248,6 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
|
||||
);
|
||||
}
|
||||
|
||||
private static function crossTenantCompareActionVisible(Tenant $record): bool
|
||||
{
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $workspaceResolver->isMember($user, $workspace)
|
||||
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $tenantResolver */
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
|
||||
return $user->canAccessTenant($record)
|
||||
&& $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$tenants = $records
|
||||
->filter(fn ($record): bool => $record instanceof Tenant)
|
||||
->values();
|
||||
|
||||
if ($records->count() !== 2 || $tenants->count() !== 2) {
|
||||
return 'Select exactly two tenants to compare.';
|
||||
}
|
||||
|
||||
if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) {
|
||||
return 'Only active tenants can be compared.';
|
||||
}
|
||||
|
||||
$workspaceIds = $tenants
|
||||
->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($workspaceIds->count() !== 1) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$workspaceId = $workspaceIds->first();
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $workspaceResolver->isMember($user, $workspace)
|
||||
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $tenantResolver */
|
||||
$tenantResolver = app(CapabilityResolver::class);
|
||||
|
||||
$isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant)
|
||||
|| ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW));
|
||||
|
||||
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string
|
||||
{
|
||||
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
|
||||
|
||||
return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}";
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string
|
||||
{
|
||||
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
|
||||
|
||||
return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)";
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string
|
||||
{
|
||||
$inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire));
|
||||
|
||||
return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array
|
||||
{
|
||||
if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tableRecords = $livewire->getTableRecords();
|
||||
|
||||
if (method_exists($tableRecords, 'getCollection')) {
|
||||
$tableRecords = $tableRecords->getCollection();
|
||||
}
|
||||
|
||||
return collect($tableRecords)
|
||||
->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive())
|
||||
->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string
|
||||
{
|
||||
$triageState = $livewire instanceof Pages\ListTenants
|
||||
? static::currentPortfolioTriageState($livewire)
|
||||
: [];
|
||||
|
||||
if (! static::hasActivePortfolioTriageState(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
)) {
|
||||
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||
}
|
||||
|
||||
$tenants = $records
|
||||
->filter(fn ($record): bool => $record instanceof Tenant)
|
||||
->values();
|
||||
|
||||
return static::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $tenants->get(1),
|
||||
triageState: $triageState,
|
||||
sourceTenant: $tenants->get(0),
|
||||
);
|
||||
}
|
||||
|
||||
private static function hasActivePortfolioTriageState(
|
||||
array $backupPostures,
|
||||
array $recoveryEvidence,
|
||||
|
||||
@ -215,7 +215,6 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
|
||||
->columnSpanFull()
|
||||
->fontFamily('mono')
|
||||
->size(TextSize::ExtraSmall),
|
||||
@ -648,19 +647,12 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
return [
|
||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||
'customer_workspace_mode' => static::isCustomerWorkspaceMode(),
|
||||
'reason_semantics' => static::isCustomerWorkspaceMode()
|
||||
? []
|
||||
: $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'highlights' => $highlights,
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
|
||||
'metrics' => static::isCustomerWorkspaceMode() ? [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
] : [
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
'metrics' => [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
@ -672,13 +664,13 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:?string,description:string}>
|
||||
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array
|
||||
private static function summaryContextLinks(TenantReview $record): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.operation'),
|
||||
'label' => __('localization.review.open_operation'),
|
||||
@ -687,7 +679,7 @@ private static function summaryContextLinks(TenantReview $record, bool $customer
|
||||
];
|
||||
}
|
||||
|
||||
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
|
||||
if ($record->currentExportReviewPack && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.executive_pack'),
|
||||
'label' => __('localization.review.view_executive_pack'),
|
||||
@ -706,25 +698,11 @@ private static function summaryContextLinks(TenantReview $record, bool $customer
|
||||
}
|
||||
|
||||
if ($record->evidenceSnapshot && $record->tenant) {
|
||||
$user = auth()->user();
|
||||
$canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant);
|
||||
$evidenceUrl = $canViewEvidence
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null;
|
||||
|
||||
if ($customerWorkspaceMode && $evidenceUrl !== null) {
|
||||
$evidenceUrl = static::appendQuery($evidenceUrl, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'title' => __('localization.review.evidence_snapshot'),
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'url' => $evidenceUrl,
|
||||
'description' => $canViewEvidence
|
||||
? __('localization.review.evidence_snapshot_description')
|
||||
: __('localization.review.evidence_proof_access_unavailable'),
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||
'description' => __('localization.review.evidence_snapshot_description'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -805,21 +783,4 @@ private static function findingOutcomeSummary(array $summary): ?string
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
|
||||
private static function isCustomerWorkspaceMode(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,18 +6,15 @@
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions;
|
||||
@ -67,12 +64,6 @@ protected function authorizeAccess(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if ($this->isCustomerWorkspaceView()) {
|
||||
return [
|
||||
$this->downloadCurrentReviewPackAction(),
|
||||
];
|
||||
}
|
||||
|
||||
$secondaryActions = $this->secondaryLifecycleActions();
|
||||
|
||||
return array_values(array_filter([
|
||||
@ -352,74 +343,6 @@ private function archiveReviewAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function downloadCurrentReviewPackAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('download_current_review_pack')
|
||||
->label(__('localization.review.download_current_review_pack'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('primary')
|
||||
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
|
||||
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
|
||||
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
private function currentReviewPackDownloadUrl(): ?string
|
||||
{
|
||||
$pack = $this->record->currentExportReviewPack;
|
||||
$tenant = $this->record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack || ! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentReviewPackUnavailableReason(): ?string
|
||||
{
|
||||
if ($this->currentReviewPackDownloadUrl() !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pack = $this->record->currentExportReviewPack;
|
||||
$tenant = $this->record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.customer_review_pack_missing');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return __('localization.review.customer_review_pack_forbidden');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.customer_review_pack_not_ready');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.customer_review_pack_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.customer_review_pack_unavailable');
|
||||
}
|
||||
|
||||
private function isCustomerWorkspaceView(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
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;
|
||||
@ -182,7 +181,6 @@ public function panel(Panel $panel): Panel
|
||||
InventoryCoverage::class,
|
||||
TenantRequiredPermissions::class,
|
||||
WorkspaceSettings::class,
|
||||
CrossTenantComparePage::class,
|
||||
GovernanceInbox::class,
|
||||
FindingsHygieneReport::class,
|
||||
FindingsIntakeQueue::class,
|
||||
|
||||
@ -139,43 +139,6 @@ 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,
|
||||
|
||||
@ -69,7 +69,6 @@ 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';
|
||||
@ -91,7 +90,6 @@ enum AuditActionId: string
|
||||
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
|
||||
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
|
||||
case EvidenceSnapshotExpired = 'evidence_snapshot.expired';
|
||||
case EvidenceSnapshotOpened = 'evidence_snapshot.opened';
|
||||
case TenantReviewCreated = 'tenant_review.created';
|
||||
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
||||
case TenantReviewPublished = 'tenant_review.published';
|
||||
@ -99,7 +97,6 @@ enum AuditActionId: string
|
||||
case TenantReviewOpened = 'tenant_review.opened';
|
||||
case TenantReviewExported = 'tenant_review.exported';
|
||||
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
||||
case CustomerReviewWorkspaceOpened = 'customer_review_workspace.opened';
|
||||
case ReviewPackDownloaded = 'review_pack.downloaded';
|
||||
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||
@ -221,7 +218,6 @@ 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',
|
||||
@ -243,7 +239,6 @@ private static function labels(): array
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened',
|
||||
self::TenantReviewCreated->value => 'Tenant review created',
|
||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||
self::TenantReviewPublished->value => 'Tenant review published',
|
||||
@ -251,7 +246,6 @@ private static function labels(): array
|
||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened',
|
||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||
@ -318,7 +312,6 @@ 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',
|
||||
@ -341,7 +334,6 @@ private static function summaries(): array
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened',
|
||||
self::TenantReviewCreated->value => 'Tenant review created',
|
||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||
self::TenantReviewPublished->value => 'Tenant review published',
|
||||
@ -349,7 +341,6 @@ private static function summaries(): array
|
||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened',
|
||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||
self::SupportRequestCreated->value => 'Support request created',
|
||||
|
||||
@ -6,15 +6,14 @@
|
||||
|
||||
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;
|
||||
@ -22,12 +21,14 @@
|
||||
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
|
||||
@ -40,7 +41,6 @@
|
||||
private const FAMILY_ORDER = [
|
||||
'assigned_findings',
|
||||
'intake_findings',
|
||||
'finding_exceptions',
|
||||
'stale_operations',
|
||||
'alert_delivery_failures',
|
||||
'review_follow_up',
|
||||
@ -71,7 +71,6 @@ public function build(
|
||||
array $visibleFindingTenants,
|
||||
array $reviewTenants,
|
||||
bool $canViewAlerts,
|
||||
bool $canViewFindingExceptions = false,
|
||||
?Tenant $selectedTenant = null,
|
||||
?string $selectedFamily = null,
|
||||
?CanonicalNavigationContext $navigationContext = null,
|
||||
@ -114,22 +113,6 @@ 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,
|
||||
@ -208,59 +191,6 @@ 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>
|
||||
@ -547,10 +477,28 @@ private function reviewFollowUpSection(
|
||||
'label' => 'Review follow-up',
|
||||
'count' => count($rawEntries),
|
||||
'summary' => $this->reviewSummary($followUpCount, $changedCount),
|
||||
'dominant_action_label' => 'Open customer review workspace',
|
||||
'dominant_action_label' => 'Open review follow-up',
|
||||
'dominant_action_url' => $selectedTenant instanceof Tenant
|
||||
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
|
||||
: $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
|
||||
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
[
|
||||
'backup_posture' => [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
],
|
||||
'recovery_evidence' => [
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||
],
|
||||
'review_state' => [
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
||||
],
|
||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
],
|
||||
)),
|
||||
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
|
||||
'empty_state' => $selectedTenant instanceof Tenant
|
||||
? 'No review follow-up is visible for this tenant filter right now.'
|
||||
@ -686,62 +634,6 @@ 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>
|
||||
*/
|
||||
@ -835,52 +727,6 @@ 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>
|
||||
@ -1009,39 +855,6 @@ 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;
|
||||
@ -1072,4 +885,4 @@ private function appendQuery(string $url, array $query): string
|
||||
|
||||
return $url.$separator.http_build_query($query);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,8 @@
|
||||
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
|
||||
@ -20,7 +18,6 @@ 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 = [],
|
||||
@ -59,42 +56,12 @@ 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>
|
||||
*/
|
||||
@ -150,7 +117,6 @@ 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 !== '');
|
||||
|
||||
@ -1,416 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
<?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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -138,12 +138,10 @@
|
||||
'latest_review' => 'Letztes Review',
|
||||
'key_findings' => 'Wichtige Findings',
|
||||
'accepted_risks' => 'Akzeptierte Risiken',
|
||||
'evidence_proof' => 'Evidence-Nachweis',
|
||||
'published' => 'Veröffentlicht',
|
||||
'review_pack' => 'Review-Pack',
|
||||
'open_latest_review' => 'Letztes Review öffnen',
|
||||
'download_review_pack' => 'Review-Pack herunterladen',
|
||||
'download_current_review_pack' => 'Aktuelles Review-Pack herunterladen',
|
||||
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
|
||||
'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
||||
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
||||
@ -156,28 +154,8 @@
|
||||
'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).',
|
||||
'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.',
|
||||
'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.',
|
||||
'accepted_risk_accountable' => 'Verantwortlich: :name.',
|
||||
'accepted_risk_accountable_until' => 'Verantwortlich: :name. Erneute Prüfung bis :date.',
|
||||
'accepted_risk_reason' => 'Begründung: :reason.',
|
||||
'accepted_risk_partial_accountability' => 'Die Verantwortlichkeit ist teilweise erfasst; Review-Owner-Details sind nicht vollständig verfügbar.',
|
||||
'unavailable' => 'Nicht verfügbar',
|
||||
'available' => 'Verfügbar',
|
||||
'review_pack_available' => 'Aktuelles Review-Pack verfügbar',
|
||||
'no_current_review_pack' => 'Noch kein aktuelles Review-Pack verfügbar',
|
||||
'review_pack_access_unavailable' => 'Review-Pack-Zugriff ist für dieses Konto nicht verfügbar',
|
||||
'review_pack_unavailable' => 'Review-Pack ist noch nicht bereit',
|
||||
'review_pack_expired' => 'Review-Pack abgelaufen',
|
||||
'evidence_proof_available' => 'Nachweiszusammenfassung verfügbar',
|
||||
'evidence_proof_absent' => 'Noch keine Nachweiszusammenfassung verknüpft',
|
||||
'evidence_proof_access_unavailable' => 'Nachweiszugriff ist für dieses Konto nicht verfügbar',
|
||||
'evidence_proof_expired' => 'Nachweiszusammenfassung abgelaufen',
|
||||
'customer_review_pack_unavailable' => 'Das aktuelle Review-Pack kann aus diesem kundensicheren Flow nicht heruntergeladen werden.',
|
||||
'customer_review_pack_missing' => 'Diesem veröffentlichten Review ist noch kein aktuelles Review-Pack zugeordnet.',
|
||||
'customer_review_pack_not_ready' => 'Das zugeordnete Review-Pack ist noch nicht für den Download bereit.',
|
||||
'customer_review_pack_expired' => 'Das zugeordnete Review-Pack ist abgelaufen.',
|
||||
'customer_review_pack_forbidden' => 'Dieses Konto kann das Review lesen, aber das aktuelle Review-Pack nicht herunterladen.',
|
||||
'released_governance_record' => 'Veröffentlichter Governance-Nachweis',
|
||||
'released_governance_record_available' => 'Dieses veröffentlichte Review ist für kundensichere Governance-Nutzung verfügbar.',
|
||||
'outcome_summary' => 'Ergebniszusammenfassung',
|
||||
'review' => 'Review',
|
||||
'review_date' => 'Review-Datum',
|
||||
|
||||
@ -138,12 +138,10 @@
|
||||
'latest_review' => 'Latest review',
|
||||
'key_findings' => 'Key findings',
|
||||
'accepted_risks' => 'Accepted risks',
|
||||
'evidence_proof' => 'Evidence proof',
|
||||
'published' => 'Published',
|
||||
'review_pack' => 'Review pack',
|
||||
'open_latest_review' => 'Open latest review',
|
||||
'download_review_pack' => 'Download review pack',
|
||||
'download_current_review_pack' => 'Download current review pack',
|
||||
'no_entitled_tenants' => 'No entitled tenants match this view',
|
||||
'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.',
|
||||
'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.',
|
||||
@ -156,28 +154,8 @@
|
||||
'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).',
|
||||
'accepted_risks_governed' => ':count accepted risks are governed.',
|
||||
'accepted_risks_on_record' => ':count accepted risks are on record.',
|
||||
'accepted_risk_accountable' => 'Accountable: :name.',
|
||||
'accepted_risk_accountable_until' => 'Accountable: :name. Re-review by :date.',
|
||||
'accepted_risk_reason' => 'Reason: :reason.',
|
||||
'accepted_risk_partial_accountability' => 'Accountability is partially recorded; review owner details are not fully available.',
|
||||
'unavailable' => 'Unavailable',
|
||||
'available' => 'Available',
|
||||
'review_pack_available' => 'Current review pack available',
|
||||
'no_current_review_pack' => 'No current review pack available yet',
|
||||
'review_pack_access_unavailable' => 'Review pack access is unavailable for this actor',
|
||||
'review_pack_unavailable' => 'Review pack is not ready yet',
|
||||
'review_pack_expired' => 'Review pack expired',
|
||||
'evidence_proof_available' => 'Proof summary available',
|
||||
'evidence_proof_absent' => 'No proof summary linked yet',
|
||||
'evidence_proof_access_unavailable' => 'Proof access is unavailable for this actor',
|
||||
'evidence_proof_expired' => 'Proof summary expired',
|
||||
'customer_review_pack_unavailable' => 'The current review pack cannot be downloaded from this customer-safe flow.',
|
||||
'customer_review_pack_missing' => 'No current review pack is attached to this released review yet.',
|
||||
'customer_review_pack_not_ready' => 'The attached review pack is not ready for download yet.',
|
||||
'customer_review_pack_expired' => 'The attached review pack has expired.',
|
||||
'customer_review_pack_forbidden' => 'This account can read the review but cannot download the current review pack.',
|
||||
'released_governance_record' => 'Released governance record',
|
||||
'released_governance_record_available' => 'This released review is available for customer-safe governance consumption.',
|
||||
'outcome_summary' => 'Outcome summary',
|
||||
'review' => 'Review',
|
||||
'review_date' => 'Review date',
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
||||
$compressedOutcome = is_array($state['compressed_outcome'] ?? null) ? $state['compressed_outcome'] : [];
|
||||
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
|
||||
$customerWorkspaceMode = (bool) ($state['customer_workspace_mode'] ?? false);
|
||||
$decisionDirection = is_string($compressedOutcome['decisionDirection'] ?? null)
|
||||
? trim((string) $compressedOutcome['decisionDirection'])
|
||||
: null;
|
||||
@ -111,20 +110,14 @@
|
||||
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
|
||||
@endphp
|
||||
|
||||
@continue($title === null || $label === null)
|
||||
@continue($title === null || $label === null || $url === null)
|
||||
|
||||
<div class="rounded-lg 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">{{ $title }}</div>
|
||||
<div class="mt-2">
|
||||
@if ($url !== null)
|
||||
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||
{{ $label }}
|
||||
</x-filament::link>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ __('localization.review.unavailable') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||
{{ $label }}
|
||||
</x-filament::link>
|
||||
</div>
|
||||
|
||||
@if ($description !== null && trim($description) !== '')
|
||||
@ -137,15 +130,9 @@
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $customerWorkspaceMode ? __('localization.review.released_governance_record') : __('localization.review.publication_readiness') }}
|
||||
</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div>
|
||||
|
||||
@if ($customerWorkspaceMode)
|
||||
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{{ __('localization.review.released_governance_record_available') }}
|
||||
</div>
|
||||
@elseif ($publishBlockers === [] && $decisionDirection === 'publishable')
|
||||
@if ($publishBlockers === [] && $decisionDirection === 'publishable')
|
||||
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{{ __('localization.review.ready_for_publication') }}
|
||||
</div>
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
<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>
|
||||
@ -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, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
|
||||
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -161,4 +161,4 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
@ -57,7 +57,7 @@
|
||||
|
||||
Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenantPublished->getKey(),
|
||||
'workspace_id' => (int) $tenantPublished->workspace_id,
|
||||
'tenant_review_id' => (int) $publishedReview->getKey(),
|
||||
@ -67,8 +67,6 @@
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$publishedReview->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
@ -85,8 +83,6 @@
|
||||
->waitForText('Customer-safe review workspace')
|
||||
->assertSee('Clear filters')
|
||||
->assertSee('Open latest review')
|
||||
->assertSee('Current review pack available')
|
||||
->assertSee('Proof summary available')
|
||||
->assertDontSee('Publish review')
|
||||
->assertDontSee('Refresh review')
|
||||
->click('Clear filters')
|
||||
@ -94,8 +90,6 @@
|
||||
->assertSee('No published review available yet')
|
||||
->click('Open latest review')
|
||||
->waitForText('Outcome summary')
|
||||
->assertSee('Download current review pack')
|
||||
->assertSee('Released governance record')
|
||||
->assertDontSee('Publish review')
|
||||
->assertDontSee('Refresh review')
|
||||
->assertDontSee('Create next review')
|
||||
@ -103,4 +97,4 @@
|
||||
->assertDontSee('Archive review')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
});
|
||||
@ -1,119 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
@ -34,32 +31,3 @@
|
||||
->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue()
|
||||
->and(data_get($expiredAudit?->metadata, 'reason'))->toBe('Evidence basis is obsolete.');
|
||||
});
|
||||
|
||||
it('records audit entries when customer review proof is opened explicitly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 1],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
->assertOk();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::EvidenceSnapshotOpened->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->resource_type)->toBe('evidence_snapshot')
|
||||
->and(data_get($audit?->metadata, 'evidence_snapshot_id'))->toBe((int) $snapshot->getKey())
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE);
|
||||
});
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
@ -420,55 +419,6 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
|
||||
->assertSeeText('Copy JSON');
|
||||
});
|
||||
|
||||
it('hides evidence refresh, expiry, operation, fingerprint, and raw json in the customer review proof flow', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create();
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 1],
|
||||
'fingerprint' => hash('sha256', 'customer-proof-flow'),
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
EvidenceSnapshotItem::query()->create([
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'dimension_key' => 'findings_summary',
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'summary_payload' => ['count' => 1, 'open_count' => 0],
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('Evidence dimensions')
|
||||
->assertDontSee('Open the latest evidence refresh operation.')
|
||||
->assertDontSee('customer-proof-flow')
|
||||
->assertDontSee('Raw summary JSON')
|
||||
->assertDontSee('Copy JSON');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::withQueryParams(['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
||||
->actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionDoesNotExist('refresh_evidence')
|
||||
->assertActionDoesNotExist('expire_snapshot');
|
||||
});
|
||||
|
||||
it('hides expire actions for expired snapshots on list and detail surfaces', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,56 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,54 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -5,7 +5,6 @@
|
||||
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;
|
||||
@ -47,28 +46,6 @@
|
||||
->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([
|
||||
@ -110,15 +87,13 @@
|
||||
->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 customer review workspace');
|
||||
->assertSee('Open review follow-up');
|
||||
});
|
||||
|
||||
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
|
||||
@ -165,48 +140,4 @@
|
||||
->assertSee('Alert delivery failures')
|
||||
->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');
|
||||
});
|
||||
});
|
||||
@ -1,85 +0,0 @@
|
||||
<?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());
|
||||
});
|
||||
@ -1,97 +0,0 @@
|
||||
<?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();
|
||||
});
|
||||
@ -1,188 +0,0 @@
|
||||
<?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);
|
||||
});
|
||||
@ -1,82 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,62 +0,0 @@
|
||||
<?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);
|
||||
});
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\ReviewPackService;
|
||||
@ -68,8 +67,6 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
]);
|
||||
$packCount = ReviewPack::query()->count();
|
||||
$operationRunCount = OperationRun::query()->count();
|
||||
|
||||
$response = $this->actingAs($user)->get($signedUrl);
|
||||
|
||||
@ -85,9 +82,7 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->resource_type)->toBe('review_pack')
|
||||
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace')
|
||||
->and(ReviewPack::query()->count())->toBe($packCount)
|
||||
->and(OperationRun::query()->count())->toBe($operationRunCount);
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
|
||||
});
|
||||
|
||||
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
@ -645,40 +644,6 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||
->assertActionVisible('regenerate');
|
||||
});
|
||||
|
||||
it('hides regenerate and raw pack diagnostics in the customer review pack flow', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'sha256' => hash('sha256', 'customer-pack-flow'),
|
||||
'fingerprint' => hash('sha256', 'customer-pack-fingerprint'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('Outcome summary')
|
||||
->assertDontSee('Regenerate')
|
||||
->assertDontSee('SHA-256')
|
||||
->assertDontSee('Fingerprint')
|
||||
->assertDontSee('Include PII')
|
||||
->assertDontSee('Include operations');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::withQueryParams(['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
||||
->actingAs($user)
|
||||
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||
->assertActionVisible('download')
|
||||
->assertActionDoesNotExist('regenerate');
|
||||
});
|
||||
|
||||
// ─── Non-Member Access ───────────────────────────────────────
|
||||
|
||||
it('returns 404 for non-members on list page', function (): void {
|
||||
|
||||
@ -3,12 +3,10 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@ -48,16 +46,6 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
||||
->assertOk();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::CustomerReviewWorkspaceOpened->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->resource_type)->toBe('customer_review_workspace')
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE)
|
||||
->and(data_get($audit?->metadata, 'entitled_tenant_count'))->toBe(1);
|
||||
});
|
||||
|
||||
it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void {
|
||||
@ -75,4 +63,4 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id])
|
||||
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey())
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
@ -136,32 +136,17 @@
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
Storage::disk('exports')->put('review-packs/customer-workspace-detail-download.zip', 'PK-test');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => 'review-packs/customer-workspace-detail-download.zip',
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
|
||||
->actingAs($user)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertSee('Outcome summary')
|
||||
->assertActionVisible('download_current_review_pack')
|
||||
->assertActionDoesNotExist('publish_review')
|
||||
->assertActionDoesNotExist('refresh_review')
|
||||
->assertActionDoesNotExist('create_next_review')
|
||||
->assertActionDoesNotExist('export_executive_pack')
|
||||
->assertActionDoesNotExist('archive_review');
|
||||
->assertActionHidden('archive_review');
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::TenantReviewOpened->value)
|
||||
@ -172,4 +157,4 @@
|
||||
->and($audit?->resource_type)->toBe('tenant_review')
|
||||
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
|
||||
});
|
||||
});
|
||||
@ -1,61 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -6,15 +6,12 @@
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -69,7 +66,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertTableActionVisible('open_latest_review', $tenant)
|
||||
->assertTableActionVisible('download_review_pack', $tenant)
|
||||
->assertSee('Current review pack available');
|
||||
->assertSee('Available');
|
||||
});
|
||||
|
||||
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
|
||||
@ -107,7 +104,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertTableActionVisible('open_latest_review', $tenant)
|
||||
->assertTableActionVisible('download_review_pack', $tenant)
|
||||
->assertSee('Current review pack available');
|
||||
->assertSee('Available');
|
||||
});
|
||||
|
||||
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
|
||||
@ -131,52 +128,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertTableActionVisible('open_latest_review', $tenant)
|
||||
->assertTableActionHidden('download_review_pack', $tenant)
|
||||
->assertSee('No current review pack available yet');
|
||||
});
|
||||
|
||||
it('distinguishes expired and capability-blocked review-pack states on the workspace', function (): void {
|
||||
$expiredTenant = Tenant::factory()->create(['name' => 'Expired Pack Tenant']);
|
||||
[$user, $expiredTenant] = createUserWithTenant(tenant: $expiredTenant, role: 'readonly');
|
||||
$blockedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $expiredTenant->workspace_id,
|
||||
'name' => 'Blocked Pack Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $blockedTenant, user: $user, role: 'readonly');
|
||||
|
||||
foreach ([$expiredTenant, $blockedTenant] as $tenant) {
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'expires_at' => $tenant->is($expiredTenant) ? now()->subDay() : now()->addDay(),
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
}
|
||||
|
||||
Gate::define(Capabilities::REVIEW_PACK_VIEW, fn (User $actor, Tenant $tenant): bool => ! $tenant->is($blockedTenant));
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $expiredTenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertCanSeeTableRecords([$expiredTenant->fresh(), $blockedTenant->fresh()])
|
||||
->assertSee('Review pack expired')
|
||||
->assertSee('Review pack access is unavailable for this actor')
|
||||
->assertTableActionHidden('download_review_pack', $expiredTenant)
|
||||
->assertTableActionHidden('download_review_pack', $blockedTenant);
|
||||
->assertSee('Unavailable');
|
||||
});
|
||||
|
||||
it('hides review and pack actions for tenants without a published review', function (): void {
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantReviewStatus;
|
||||
@ -142,59 +140,6 @@
|
||||
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false);
|
||||
});
|
||||
|
||||
it('summarizes accepted-risk accountability and evidence proof availability in customer-safe workspace rows', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Governed Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$owner = User::factory()->create(['name' => 'Risk Owner']);
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'risk_acceptance' => [
|
||||
'status_marked_count' => 1,
|
||||
'valid_governed_count' => 1,
|
||||
'warning_count' => 0,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'status' => FindingException::STATUS_ACTIVE,
|
||||
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'request_reason' => 'Vendor patch window accepted by the customer.',
|
||||
'owner_user_id' => (int) $owner->getKey(),
|
||||
'approved_by_user_id' => (int) $owner->getKey(),
|
||||
'requested_at' => now()->subDays(2),
|
||||
'approved_at' => now()->subDay(),
|
||||
'effective_from' => now()->subDay(),
|
||||
'review_due_at' => now()->addDays(14),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertCanSeeTableRecords([$tenant->fresh()])
|
||||
->assertSee('1 accepted risks are governed. Accountable: Risk Owner. Re-review by')
|
||||
->assertSee('Reason: Vendor patch window accepted by the customer.')
|
||||
->assertSee('Proof summary available');
|
||||
});
|
||||
|
||||
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
|
||||
@ -274,4 +219,4 @@
|
||||
->filterTable('tenant_id', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$tenantA->fresh()])
|
||||
->assertCanNotSeeTableRecords([$tenantB->fresh()]);
|
||||
});
|
||||
});
|
||||
@ -3,10 +3,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
|
||||
@ -64,35 +62,3 @@
|
||||
->assertSee($registerOutcome?->primaryReason ?? '')
|
||||
->assertSee($explanation?->nextActionText ?? '');
|
||||
});
|
||||
|
||||
it('keeps customer-workspace review detail customer-readable by hiding internal reason ownership and fingerprints', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => 'published',
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
expect($review->operation_run_id)->not->toBeNull();
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([
|
||||
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('Released governance record')
|
||||
->assertSee('This released review is available for customer-safe governance consumption.')
|
||||
->assertSee('Evidence snapshot')
|
||||
->assertSee('source_surface=customer_review_workspace', false)
|
||||
->assertDontSee('Reason owner')
|
||||
->assertDontSee('Platform reason family')
|
||||
->assertDontSee('Fingerprint')
|
||||
->assertDontSee(OperationRunLinks::tenantlessView((int) $review->operation_run_id), false)
|
||||
->assertDontSee('Inspect the latest review composition or refresh run.');
|
||||
});
|
||||
|
||||
@ -3,31 +3,23 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
function tenantReviewContractHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
@ -169,54 +161,6 @@ function tenantReviewContractHeaderActions(Testable $component): array
|
||||
->and($groupLabels)->toBe(['More', 'Danger']);
|
||||
});
|
||||
|
||||
it('uses the current review-pack download as the only customer-workspace detail header action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
Storage::disk('exports')->put('review-packs/customer-detail-primary.zip', 'PK-test');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => 'review-packs/customer-detail-primary.zip',
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$component = Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
|
||||
->actingAs($user)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertActionVisible('download_current_review_pack')
|
||||
->assertActionEnabled('download_current_review_pack')
|
||||
->assertActionDoesNotExist('publish_review')
|
||||
->assertActionDoesNotExist('refresh_review')
|
||||
->assertActionDoesNotExist('create_next_review')
|
||||
->assertActionDoesNotExist('export_executive_pack')
|
||||
->assertActionDoesNotExist('archive_review');
|
||||
|
||||
$topLevelActionNames = collect(tenantReviewContractHeaderActions($component))
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($topLevelActionNames)->toBe(['download_current_review_pack']);
|
||||
});
|
||||
|
||||
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -52,26 +52,3 @@
|
||||
->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');
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantTriageReview;
|
||||
@ -55,28 +54,6 @@
|
||||
'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([
|
||||
@ -152,7 +129,6 @@
|
||||
visibleFindingTenants: [$alphaTenant, $bravoTenant],
|
||||
reviewTenants: [$alphaTenant, $bravoTenant],
|
||||
canViewAlerts: true,
|
||||
canViewFindingExceptions: true,
|
||||
navigationContext: $context,
|
||||
);
|
||||
|
||||
@ -160,7 +136,6 @@
|
||||
->toBe([
|
||||
'assigned_findings',
|
||||
'intake_findings',
|
||||
'finding_exceptions',
|
||||
'stale_operations',
|
||||
'alert_delivery_failures',
|
||||
'review_follow_up',
|
||||
@ -168,7 +143,6 @@
|
||||
->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,
|
||||
@ -179,9 +153,6 @@
|
||||
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')
|
||||
@ -225,64 +196,4 @@
|
||||
->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures')
|
||||
->and($payload['sections'][0]['count'])->toBe(0)
|
||||
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@ -1,192 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
|
||||
@ -1,90 +1,154 @@
|
||||
# Microsoft Graph API Permissions
|
||||
|
||||
> **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
|
||||
This document lists all required Microsoft Graph API permissions for TenantPilot to function correctly.
|
||||
|
||||
This document summarizes the permission registry currently defined in:
|
||||
## Required Permissions
|
||||
|
||||
- `apps/platform/config/intune_permissions.php`
|
||||
- `apps/platform/config/entra_permissions.php`
|
||||
The Azure AD / Entra ID **App Registration** used by TenantPilot requires the following **Application Permissions** (not Delegated):
|
||||
|
||||
These config files are the repo source of truth for currently implemented permission requirements.
|
||||
### 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
|
||||
|
||||
## Scope Rules
|
||||
### 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
|
||||
|
||||
- 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.
|
||||
### 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)
|
||||
|
||||
## Current Required Permissions
|
||||
## How to Add Permissions
|
||||
|
||||
### Intune Configuration, Backup, Restore, and Drift
|
||||
### Azure Portal (Entra ID)
|
||||
|
||||
| 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 |
|
||||
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!
|
||||
|
||||
### Conditional Access And Policy Coverage
|
||||
### PowerShell (Alternative)
|
||||
|
||||
| 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 |
|
||||
```powershell
|
||||
# Connect to Microsoft Graph
|
||||
Connect-MgGraph -Scopes "Application.ReadWrite.All"
|
||||
|
||||
### Directory, Groups, And Intune RBAC Foundations
|
||||
# Get your app registration
|
||||
$appId = "YOUR-APP-CLIENT-ID"
|
||||
$app = Get-MgApplication -Filter "appId eq '$appId'"
|
||||
|
||||
| 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 |
|
||||
# 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"}
|
||||
|
||||
### Entra Admin Roles Evidence
|
||||
$requiredResourceAccess = @{
|
||||
ResourceAppId = "00000003-0000-0000-c000-000000000000"
|
||||
ResourceAccess = @(
|
||||
@{
|
||||
Id = $rbacPermission.Id
|
||||
Type = "Role"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
| Permission | Why the repo requires it |
|
||||
|---|---|
|
||||
| `RoleManagement.Read.Directory` | Read directory role definitions and assignments for Entra admin roles evidence and findings |
|
||||
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
|
||||
|
||||
## Not Currently Required By Implemented Features
|
||||
|
||||
These permissions may appear in research, roadmap ideas, or tenant-specific grants, but they are not part of the current required-permission registry:
|
||||
|
||||
- `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:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan cache:clear
|
||||
# Grant admin consent
|
||||
# (Must be done manually or via Graph API with RoleManagement.ReadWrite.Directory scope)
|
||||
```
|
||||
|
||||
## Least-Privilege Notes
|
||||
## Verification
|
||||
|
||||
- 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.
|
||||
After adding permissions and granting admin consent:
|
||||
|
||||
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"
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 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)
|
||||
|
||||
## References
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
|
||||
@ -2,11 +2,6 @@
|
||||
|
||||
---
|
||||
|
||||
> **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.
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
@ -1,10 +1,5 @@
|
||||
# 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)
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
# 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.
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
@ -1,25 +1,47 @@
|
||||
# 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-04-30
|
||||
**Last reviewed**: 2026-03-15
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 — 2026-03-15 architecture hardening cluster moved out of discoveries
|
||||
## 2026-03-15 — Queued execution trust relies too much on dispatch-time authority
|
||||
- **Source**: architecture audit
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
@ -20,7 +15,7 @@ ## Purpose
|
||||
|
||||
## Current Product Position
|
||||
|
||||
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls 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.
|
||||
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls und inzwischen repo-real umgesetzten Customer-safe Review Consumption, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance bleiben unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
|
||||
|
||||
## Status Model
|
||||
|
||||
@ -46,7 +41,7 @@ ## 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 | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen, aber die Customer-Consumption-Productization bleibt unvollstaendig. |
|
||||
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | yes | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen. |
|
||||
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
|
||||
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
|
||||
| UI & Product Maturity Polish | implemented_partial | 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. |
|
||||
@ -55,12 +50,12 @@ ## Roadmap Coverage Summary
|
||||
| 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 | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real, aber die Customer-Review-Consumption ist noch nicht voll productized. |
|
||||
| R2 Completion: customer review, support, help | adopted | strong | yes | repo tests, not run | yes | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real. |
|
||||
| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. |
|
||||
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
|
||||
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
|
||||
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
|
||||
| Private AI Execution Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
|
||||
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
|
||||
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
|
||||
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow 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. |
|
||||
@ -80,7 +75,7 @@ ## Implemented Capabilities
|
||||
| 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` |
|
||||
| Customer review workspace | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` |
|
||||
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
|
||||
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
|
||||
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
|
||||
@ -115,7 +110,7 @@ ## Foundation-Only Capabilities
|
||||
|
||||
## Partial Capabilities
|
||||
|
||||
- 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.
|
||||
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber portfolio-weite Consumption- und Sharing-Patterns bleiben offen.
|
||||
- Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen.
|
||||
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
|
||||
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
|
||||
@ -124,7 +119,7 @@ ## Partial Capabilities
|
||||
|
||||
## Planned But Not Implemented
|
||||
|
||||
- Private AI Execution Governance Foundation
|
||||
- Private AI Execution & Usage Governance Foundation
|
||||
- Human-in-the-Loop Autonomous Governance
|
||||
- Standardization & Policy Quality / Intune Linting
|
||||
- PSA / Ticketing Handoff
|
||||
@ -137,10 +132,9 @@ ## 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 | 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. |
|
||||
| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; breitere Commercial-Polish-Themen bleiben separat. |
|
||||
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. |
|
||||
| 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. |
|
||||
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
|
||||
|
||||
## Commercial Readiness
|
||||
|
||||
@ -148,14 +142,14 @@ ### Demo-ready
|
||||
|
||||
- Baseline compare and drift walkthroughs
|
||||
- Review pack generation and export
|
||||
- Customer review workspace walkthroughs with operator guidance
|
||||
- Customer-safe review workspace walkthroughs
|
||||
- Provider health, onboarding readiness and required permissions
|
||||
- Support diagnostics
|
||||
- Permission posture and Entra admin roles reporting
|
||||
|
||||
### Almost sellable
|
||||
|
||||
- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs, aber noch nicht als vollstaendig productisierte customer-safe consumption experience
|
||||
- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs
|
||||
- Baseline drift and restore governance
|
||||
- Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling
|
||||
- Alerting and run visibility for governance operations
|
||||
@ -180,46 +174,39 @@ ### Foundation-only
|
||||
### Not sellable yet
|
||||
|
||||
- Cross-Tenant Compare and Promotion 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 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 |
|
||||
| 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 Governance | P3 Private AI Execution Governance Foundation |
|
||||
| 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 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 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.
|
||||
- `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 current R2 completion. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind bereits repo-real.
|
||||
- `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo.
|
||||
- `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert.
|
||||
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
|
||||
@ -227,7 +214,7 @@ ## Roadmap Drift Notes
|
||||
- `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 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.
|
||||
- The main drift pattern is underestimation, not overestimation. Customer-facing review consumption is no longer the clearest missing piece; portfolio action and commercial lifecycle are.
|
||||
|
||||
## Evidence Sources
|
||||
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@ -1,16 +1,9 @@
|
||||
# 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-30
|
||||
**Last updated**: 2026-04-25
|
||||
|
||||
---
|
||||
|
||||
@ -23,26 +16,6 @@ ## 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
|
||||
@ -78,8 +51,6 @@ ### 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
|
||||
@ -93,7 +64,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
|
||||
|
||||
**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.
|
||||
**Active specs**: — (not yet specced)
|
||||
|
||||
### 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.
|
||||
@ -139,10 +110,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 (repo-real foundation; the next product gap is accountability-trail productization in customer-safe review, expiry/re-review visibility, and management-ready reporting)
|
||||
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
|
||||
- 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 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
|
||||
- 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
|
||||
- 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
|
||||
@ -156,24 +127,10 @@ ### 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
|
||||
|
||||
### 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`.
|
||||
### 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.
|
||||
|
||||
### Platform Operations Maturity
|
||||
- CSV export for filtered run metadata (deferred from Spec 114)
|
||||
@ -209,7 +166,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: P1–P4 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; depends on the shared Workspace, Tenant & Managed Object Lifecycle Governance taxonomy before destructive or retention-sensitive flows are implemented
|
||||
- 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
|
||||
- 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)
|
||||
@ -225,13 +182,12 @@ ### 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 Governance Foundation
|
||||
### Private AI Execution & Usage 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
|
||||
@ -256,7 +212,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 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 & 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.
|
||||
**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
|
||||
@ -265,14 +221,12 @@ ### 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.
|
||||
@ -306,12 +260,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 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.
|
||||
### 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.
|
||||
**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**:
|
||||
@ -326,13 +280,6 @@ ### Compliance Evidence Mapping v1
|
||||
- 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.
|
||||
@ -384,15 +331,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 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 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 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 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 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 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 |
|
||||
@ -409,7 +356,7 @@ ## Priority Ranking (from Product Brainstorming)
|
||||
|
||||
1. Product Scalability & Self-Service Foundation
|
||||
2. Product Usage, Customer Health & Operational Controls
|
||||
3. Private AI Execution Governance Foundation
|
||||
3. Private AI Execution & Usage Governance Foundation
|
||||
4. Decision-Based Operating / Governance Inbox
|
||||
5. MSP Portfolio + Alerting
|
||||
6. Drift + Approval Workflows
|
||||
@ -426,7 +373,6 @@ ## 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.
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
# 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-30
|
||||
> **Last reviewed**: 2026-04-28
|
||||
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
|
||||
|
||||
---
|
||||
@ -24,115 +19,70 @@ ## 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 Productization v1
|
||||
### Customer Review Workspace v1
|
||||
- **Priority**: P0
|
||||
- **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.
|
||||
- **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.
|
||||
- **Dependencies**:
|
||||
- `TenantReview`
|
||||
- `ReviewPack`
|
||||
- `EvidenceSnapshot`
|
||||
- `Finding`
|
||||
- accepted risks / exceptions workflow
|
||||
- `ReviewPack`
|
||||
- existing redaction behavior
|
||||
- stored reports and canonical control catalog foundations
|
||||
- workspace entitlements
|
||||
- tenant/workspace RBAC, audit, localization, and workspace-isolation foundations
|
||||
- tenant/workspace RBAC and audit foundations
|
||||
- **Scope**:
|
||||
- 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
|
||||
- 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
|
||||
- **Non-scope**:
|
||||
- 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
|
||||
- admin settings
|
||||
- remediation actions
|
||||
- raw operator diagnostics
|
||||
- a broader customer portal rewrite
|
||||
- billing or contract workflows
|
||||
- **Acceptance criteria**:
|
||||
- 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
|
||||
- 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
|
||||
- tenant and workspace isolation are enforced and tested
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### P1 — Enterprise Maturity
|
||||
|
||||
### Governance Decision Surface Convergence
|
||||
### Decision-Based Governance Inbox v1
|
||||
- **Priority**: P1
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 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
|
||||
- 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
|
||||
- **Non-scope**:
|
||||
- rebuilding the first governance inbox from scratch
|
||||
- autonomous remediation
|
||||
- AI-generated recommendations
|
||||
- customer-facing inboxes
|
||||
- full cross-tenant workboard redesign
|
||||
- **Acceptance criteria**:
|
||||
- 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
|
||||
- one surface shows prioritized governance work from more than one underlying signal family
|
||||
- actions route to existing product truth rather than duplicating state
|
||||
- visibility is capability-aware and workspace-safe
|
||||
- auditable state changes are recorded where the inbox mutates work state
|
||||
- tests prove signal grouping, routing, and authorization boundaries
|
||||
- **Notes**: This is a follow-up to the existing Governance Inbox, not a greenfield inbox foundation.
|
||||
- tests prove signal grouping and authorization boundaries
|
||||
- **Notes**: Important, but not a P0 release blocker while Customer Review Workspace is still missing.
|
||||
|
||||
### Cross-Tenant Compare and Promotion v1
|
||||
- **Priority**: P1
|
||||
@ -162,6 +112,32 @@ ### 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.
|
||||
@ -280,63 +256,6 @@ ### 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.
|
||||
@ -373,55 +292,7 @@ ## 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.
|
||||
|
||||
### 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.
|
||||
|
||||
- `Policy Lifecycle / Ghost Policies`: still a valid gap, but not ahead of Customer Review Workspace or Cross-Tenant Compare.
|
||||
- `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.
|
||||
@ -441,8 +312,6 @@ ## 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`)
|
||||
@ -484,5 +353,4 @@ ## 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.
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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()`.
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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)
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@ -1,10 +1,5 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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)
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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:
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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:
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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:
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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)
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
# 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:**
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight
|
||||
|
||||
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
|
||||
**Created**: 2026-04-27
|
||||
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
|
||||
**Created**: 2026-04-27
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
@ -54,6 +54,4 @@ ## 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.
|
||||
- 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.
|
||||
@ -1,6 +1,6 @@
|
||||
# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
|
||||
|
||||
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md)
|
||||
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
@ -9,45 +9,22 @@ ## 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
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
|
||||
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
|
||||
**Testing**: Pest v4 `Unit` and `Feature` coverage only
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
||||
**Project Type**: Web application (Laravel monolith with Filament pages)
|
||||
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
|
||||
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
|
||||
**Language/Version**: PHP 8.4, Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
|
||||
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
|
||||
**Testing**: Pest v4 `Unit` and `Feature` coverage only
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
||||
**Project Type**: Web application (Laravel monolith with Filament pages)
|
||||
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
|
||||
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
|
||||
**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: one new canonical compare page plus bounded row and exact-two bulk launch actions from existing tenant-registry/portfolio context
|
||||
- **Guardrail scope**: one new canonical compare page plus one launch action 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
|
||||
@ -55,7 +32,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 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
|
||||
- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly
|
||||
- **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
|
||||
|
||||
@ -1,21 +1,11 @@
|
||||
# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
|
||||
|
||||
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
|
||||
**Created**: 2026-01-07
|
||||
**Updated**: 2026-04-30
|
||||
**Status**: Implemented (read-only slice)
|
||||
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
|
||||
**Created**: 2026-01-07
|
||||
**Updated**: 2026-04-27
|
||||
**Status**: Ready for implementation
|
||||
**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.
|
||||
@ -108,7 +98,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` / `Compare selected` | 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` | 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)*
|
||||
|
||||
@ -122,7 +112,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 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 |
|
||||
| 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 |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
@ -252,7 +242,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 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.
|
||||
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`.
|
||||
2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@ -6,31 +6,31 @@
|
||||
|
||||
# Tasks: Cross-Tenant Compare Preview and Promotion Preflight
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/`
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/`
|
||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice.
|
||||
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only.
|
||||
**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`.
|
||||
**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood.
|
||||
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice.
|
||||
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only.
|
||||
**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`.
|
||||
**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood.
|
||||
**Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins.
|
||||
|
||||
- [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`.
|
||||
- [ ] 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`.
|
||||
|
||||
---
|
||||
|
||||
@ -40,11 +40,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
**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
|
||||
|
||||
- [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`.
|
||||
- [ ] 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`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
**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
|
||||
|
||||
- [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`.
|
||||
- [ ] 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`.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
**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
|
||||
|
||||
- [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`.
|
||||
- [ ] 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`.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
**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.
|
||||
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
# Preparation Review Checklist: Governance Decision Surface Convergence v1
|
||||
|
||||
**Purpose**: Verify that the preparation package is repo-grounded, narrowly scoped, and ready for a later implementation loop.
|
||||
**Created**: 2026-04-29
|
||||
**Review outcome class**: Workflow Compression
|
||||
**Workflow outcome**: approve for implementation
|
||||
**Test-governance outcome**: keep
|
||||
|
||||
## Candidate Selection
|
||||
|
||||
- [x] CHK001 The selected slice is anchored to `docs/product/roadmap.md` (`Decision-Based Operating Foundations`) and the open-gap truth in `docs/product/implementation-ledger.md`.
|
||||
- [x] CHK002 Already-specced candidates such as Spec 043, Spec 249, Spec 250, Spec 251, Spec 252, Spec 253, Spec 254, Spec 255, and Spec 256 are explicitly excluded from reopening.
|
||||
- [x] CHK003 The package chooses the smallest repo-grounded slice: extend the existing governance inbox and specialist pages instead of inventing a new shell or workflow engine.
|
||||
|
||||
## Scope And Truth
|
||||
|
||||
- [x] CHK004 The spec, plan, and tasks all state that no new persistence, inbox-item truth, queue state, or mutation lane is introduced.
|
||||
- [x] CHK005 The governance inbox remains the canonical workspace decision home, while specialist queues and the customer review workspace remain secondary-context surfaces.
|
||||
- [x] CHK006 The finding-exceptions lane and review-consumption handoff are described as derived from existing repo truth rather than a new abstraction layer.
|
||||
|
||||
## UX And Authorization
|
||||
|
||||
- [x] CHK007 The package makes one dominant next action explicit for the governance home, preserves one dominant default action on each specialist surface, and keeps specialist pages from duplicating the workspace-level summary.
|
||||
- [x] CHK008 `404` vs `403` semantics are explicit for workspace membership, tenant scope, and no-visible-family cases.
|
||||
- [x] CHK009 Hidden tenants and hidden families are omitted from counts, labels, previews, and empty-state hints.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] CHK010 Planned proof stays in focused `Unit` plus `Feature` lanes only, with explicit validation commands.
|
||||
- [x] CHK011 The tasks include explicit coverage for family assembly, arrival/return continuity, latest-published-review preference versus workspace fallback, duplicate-truth prevention, and read-only review integrity on the specialist pages.
|
||||
- [x] CHK012 The checklist records the active review outcome class and workflow outcome instead of leaving readiness implicit.
|
||||
|
||||
## Readiness Outcome
|
||||
|
||||
- [x] CHK013 The package is ready for implementation only if analysis confirms that the scope remains bounded to existing governance, findings, monitoring, and review seams and that `CustomerReviewWorkspace` stays read-only.
|
||||
- [x] CHK014 The package explicitly preserves the no-new-Graph-call, no-queue, no-`OperationRun`, and no-new-audit-stream constraints for this slice.
|
||||
- [x] CHK015 Any broader dashboard-entry, cross-tenant, or workflow-engine follow-up is listed separately rather than hidden inside this slice.
|
||||
@ -1,254 +0,0 @@
|
||||
# Implementation Plan: Governance Decision Surface Convergence v1
|
||||
|
||||
**Branch**: `257-governance-decision-convergence` | **Date**: 2026-04-29 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Tighten TenantPilot's decision-first operating model by converging onto the existing `GovernanceInbox` as the canonical workspace decision home, extending it with the still-missing finding-exceptions lane, and aligning the specialist findings, exceptions, and customer-review pages behind one truthful arrival and return model. The slice is intentionally read-only and reuses existing page, builder, and navigation seams instead of adding a new page shell, workflow state, or task engine.
|
||||
|
||||
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no new asset bundle is expected.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing governance inbox and navigation helpers
|
||||
**Storage**: PostgreSQL via existing findings, finding-exceptions, reviews, packs, alerts, and operation-run truth only
|
||||
**Testing**: Pest v4 `Unit` plus `Feature` coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
||||
**Project Type**: Web application (Laravel monolith with Filament pages)
|
||||
**Performance Goals**: derived DB-only page rendering, no new remote calls, and no queue or `OperationRun` start in v1
|
||||
**Constraints**: no new persistence, no new page shell, no new mutation lane, no customer portal scope, no duplicate truth across equal-priority cards
|
||||
**Scale/Scope**: 1 existing canonical page, 4 specialist page classes plus their Blade views, and 1 bounded section-builder extension
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
|
||||
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
|
||||
- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
|
||||
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||
- `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
|
||||
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||
- `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
|
||||
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
|
||||
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
|
||||
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
|
||||
- `apps/platform/app/Support/Badges/BadgeRenderer.php`
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- Reuse the existing `GovernanceInbox` Filament page instead of introducing a new page class or a utility shell.
|
||||
- Keep the governance home as a section-based read-only page. Add one derived exception lane and adjust review-consumption handoff copy and arrival context, but keep diagnostics and proof on the existing specialist or detail routes.
|
||||
- Preserve specialist-page ownership. `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` remain the pages where lane-specific truth and existing safe actions live.
|
||||
- Any state that must survive navigation or Livewire requests stays on public, query-backed, or existing session-backed state. Do not move convergence state into private page properties.
|
||||
- No new resource, global-search result, or panel asset registration is planned.
|
||||
|
||||
## RBAC / Policy Fit
|
||||
|
||||
- Workspace membership remains the first gate for the governance home and all converged routes.
|
||||
- Findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW`; existing inline safe actions such as claim remain on the owning specialist pages and continue to require their existing capabilities such as `Capabilities::TENANT_FINDINGS_ASSIGN`.
|
||||
- The exception lane must reuse the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE` rather than inventing a second exception-view capability.
|
||||
- The review-consumption handoff must reuse the current review and review-pack access rules instead of adding a new customer-review-workspace capability family.
|
||||
- `404` applies to non-members and out-of-scope tenant targets. `403` applies only to in-scope members who still cannot see any converged family.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- The convergence layer stays read-only and should not add a new page-view audit stream.
|
||||
- Existing mutations and downloads remain audited on their current owning surfaces.
|
||||
- No new `OperationRun`, notification stream, or navigation-event ledger is required.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- Extend `GovernanceInboxSectionBuilder` rather than creating a new persistence or projection layer.
|
||||
- The new exception lane must derive from existing `FindingException` truth and the current queue semantics, not from a copied workflow summary.
|
||||
- Review-consumption handoff should keep using the current latest-published-review vs customer-review-workspace fallback logic.
|
||||
- Family counts, previews, and empty-state decisions must be computed only after tenant and capability filtering, so hidden tenants and hidden lanes do not leak through aggregate counts.
|
||||
- Keep any new family key local to the page and builder; do not introduce a new domain enum or persisted state family.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament
|
||||
- **Shared-family relevance**: governance decision home, specialist queues, customer-review routing, navigation continuity
|
||||
- **State layers in scope**: page, URL-query, table/session restore
|
||||
- **Audience modes in scope**: operator-MSP, customer-read-only on the existing review workspace
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the governance home, diagnostics-second on specialist pages, raw/support detail remains on existing detail paths only
|
||||
- **Raw/support gating plan**: hidden by default on the governance home; existing gating remains on source/detail surfaces
|
||||
- **One-primary-action / duplicate-truth control**: governance home keeps one dominant CTA per section; specialist pages keep their existing lane-owned primary action and must not duplicate the governance-home summary
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: global-context-shell
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none planned
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the specialist findings, exception, and review pages listed above
|
||||
- **Shared abstractions reused**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing source-page action-surface declarations
|
||||
- **New abstraction introduced? why?**: at most one bounded convergence helper for arrival and return semantics if the existing navigation-context helper needs a thin extension; no new framework or registry is justified
|
||||
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for page ownership and current routing, but not yet sufficient to make the governance home the single truthful start surface across the missing exception and review-consumption lanes
|
||||
- **Bounded deviation / spread control**: no new shell or workflow engine; all implementation stays inside existing governance, findings, monitoring, reviews, and navigation seams
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` contract change
|
||||
- **Central contract reused**: the already-existing stale-operations deep-link behavior remains unchanged
|
||||
- **Delegated UX behaviors**: `N/A`
|
||||
- **Surface-owned behavior kept local**: the governance home continues to list stale operations through the existing family, but this spec does not change that family's start or completion semantics
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: existing governance and navigation vocabulary only
|
||||
- **Neutral platform terms / contracts preserved**: `Governance inbox`, `finding exceptions`, `customer review workspace`, `Open lane`, and `Back to governance inbox`
|
||||
- **Retained provider-specific semantics and why**: none new
|
||||
- **Bounded extraction or follow-up path**: `N/A`
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation preparation continues.*
|
||||
|
||||
- Inventory-first: PASS. All sections remain derived from existing findings, exceptions, reviews, alerts, and operation-run truth.
|
||||
- Read/write separation: PASS. The convergence layer remains read-only and keeps mutations on existing source surfaces.
|
||||
- Graph contract path: PASS. No new Graph or provider calls are introduced.
|
||||
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
|
||||
- Workspace and tenant isolation: PASS. Workspace membership remains first, and tenant/family omission happens before counts are exposed.
|
||||
- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion.
|
||||
- Destructive action discipline: PASS by non-use. No new destructive or risky actions are introduced.
|
||||
- Global search: PASS. No new resource or search result is added.
|
||||
- OperationRun / Ops-UX: PASS by non-use. No new run start or completion behavior exists.
|
||||
- Data minimization: PASS. Default-visible content stays limited to family summaries, lane scope, and next action.
|
||||
- Test governance: PASS. Proof remains in focused `Unit` and `Feature` lanes.
|
||||
- Proportionality / no premature abstraction: PASS. The design extends an existing page and builder instead of introducing a new shell or engine.
|
||||
- Persisted truth: PASS. No new table, artifact, or cached projection is introduced.
|
||||
- Behavioral state: PASS. Any additional family key remains derived page state only.
|
||||
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing page and navigation patterns are extended rather than bypassed.
|
||||
- Provider boundary: PASS. No provider/platform seam widens.
|
||||
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Unit` for section assembly and convergence routing, `Feature` for page visibility, family omission, and navigation continuity
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves derived-family assembly cheaply; feature coverage proves route access, family omission, tenant-prefilter continuity, and duplicate-truth prevention on existing pages
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse existing workspace, tenant, finding, exception, and review fixtures without widening into browser or heavy-governance families
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because return context and tenant-filter continuity are part of the contract
|
||||
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the governance home stays read-only, and confirm specialist surfaces preserve lane-specific truth without duplicating the workspace summary
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||
- **Review-stop questions**: lane fit, hidden fixture growth, accidental new shell, accidental new mutation lane, hidden leakage through counts
|
||||
- **Escalation path**: `document-in-feature` for contained navigation-context notes; `reject-or-split` for any new shell or workflow-engine drift
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Test-governance outcome**: keep
|
||||
- **Why no dedicated follow-up spec is needed**: the bounded convergence work remains feature-local unless future work demands a broader dashboard or portfolio action-center spec
|
||||
|
||||
## Rollout & Risk Controls
|
||||
|
||||
- Keep the governance inbox as the only primary start surface touched by this slice.
|
||||
- Keep all specialist mutations on their existing pages.
|
||||
- Do not widen the exception or review lane into new workflow state.
|
||||
- Prefer extending the current section builder and navigation helper over adding a new orchestrator.
|
||||
- Treat any attempt to add a second workspace summary banner on the specialist pages as out-of-scope drift.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/257-governance-decision-convergence/
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
This preparation package intentionally stays on the core artifacts plus the review checklist. The repo truth is already known, the slice adds no new persistence or external contract, and no extra research/data-model/contracts package is required to make the implementation bounded.
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/Pages/
|
||||
│ │ ├── Findings/
|
||||
│ │ │ ├── MyFindingsInbox.php
|
||||
│ │ │ └── FindingsIntakeQueue.php
|
||||
│ │ ├── Governance/
|
||||
│ │ │ └── GovernanceInbox.php
|
||||
│ │ ├── Monitoring/
|
||||
│ │ │ └── FindingExceptionsQueue.php
|
||||
│ │ └── Reviews/
|
||||
│ │ └── CustomerReviewWorkspace.php
|
||||
│ └── Support/
|
||||
│ ├── GovernanceInbox/
|
||||
│ │ └── GovernanceInboxSectionBuilder.php
|
||||
│ ├── Navigation/
|
||||
│ │ └── CanonicalNavigationContext.php
|
||||
│ └── OperateHub/
|
||||
│ └── OperateHubShell.php
|
||||
└── resources/views/filament/pages/
|
||||
├── findings/
|
||||
│ ├── my-findings-inbox.blade.php
|
||||
│ └── findings-intake-queue.blade.php
|
||||
├── governance/
|
||||
│ └── governance-inbox.blade.php
|
||||
├── monitoring/
|
||||
│ └── finding-exceptions-queue.blade.php
|
||||
└── reviews/
|
||||
└── customer-review-workspace.blade.php
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| One additional derived family in `GovernanceInboxSectionBuilder` | the exception lane still sits outside the canonical decision home | leaving exceptions on a standalone specialist page keeps the current fragmented start state |
|
||||
| One bounded convergence contract for arrival and return context | specialist pages need a truthful way back to the same governance scope | page-local ad hoc back links would drift across surfaces and duplicate navigation logic |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: operators still have to decide between several repo-real specialist surfaces before they can begin work.
|
||||
- **Existing structure is insufficient because**: the current governance home does not yet own all high-signal lanes and the specialist pages do not clearly behave as secondary contexts.
|
||||
- **Narrowest correct implementation**: extend the existing governance inbox and navigation continuity instead of adding a new shell or persisted workflow engine.
|
||||
- **Ownership cost created**: maintain one more derived family, one bounded convergence helper, and focused tests.
|
||||
- **Alternative intentionally rejected**: a new action-center page or persisted cross-family work queue was rejected as unnecessary structure for current-release truth.
|
||||
- **Release truth**: current-release workflow compression.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
MVP = **User Story 1 + User Story 2 together**. The convergence slice only becomes meaningful once the governance home shows the missing lanes and the specialist surfaces preserve truthful return context.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Extend the governance inbox family assembly and page rendering.
|
||||
2. Add convergence-aware arrival and return semantics on the specialist pages.
|
||||
3. Tighten duplicate-truth prevention and calm secondary-context copy.
|
||||
4. Finish with focused validation and formatting.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the governance inbox family extension and navigation-context contract first.
|
||||
2. Parallelize unit coverage for builder behavior and feature coverage for navigation continuity.
|
||||
3. Serialize merges around the shared governance inbox and specialist page views so the decision-home language stays coherent.
|
||||
@ -1,320 +0,0 @@
|
||||
# Feature Specification: Governance Decision Surface Convergence v1
|
||||
|
||||
**Feature Branch**: `257-governance-decision-convergence`
|
||||
**Created**: 2026-04-29
|
||||
**Status**: Ready for implementation
|
||||
**Input**: User description: "Prepare the roadmap-fit Decision-Based Operating Foundations slice as Governance Decision Surface Convergence: use the existing governance inbox as the canonical workspace decision home, converge the still-missing exception and customer-review decision lanes, and keep the work read-only, repo-grounded, and free of new workflow state."
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot already has multiple repo-real decision surfaces such as `MyFindingsInbox`, `FindingsIntakeQueue`, `GovernanceInbox`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`, but operators still have to decide where to start by hopping between specialist pages.
|
||||
- **Today's failure**: The product has a decision-first foundation, but it still lacks one clearly dominant workspace decision home that converges the open exception lane and the customer-review follow-up lane into the same calm operating model.
|
||||
- **User-visible improvement**: An authorized workspace operator lands on one canonical governance home, sees the remaining high-signal lanes in one place, and can open a specialized surface with preserved context and a truthful return path instead of reconstructing the workflow manually.
|
||||
- **Smallest enterprise-capable version**: Reuse the existing `/admin/governance/inbox` page as the canonical decision home, extend it with a derived `finding_exceptions` family from existing queue truth, make customer-review follow-up handoff explicit through existing review workspace surfaces, and align `My Findings`, `Findings intake`, `Finding exceptions`, and `Customer Review Workspace` behind shared arrival and return context. No new page shell, no new persistence, and no new mutation lane ship in this slice.
|
||||
- **Explicit non-goals**: No new portal or dashboard shell, no new persisted inbox item or task state, no new assign/claim/approve/review mutations on the convergence surface, no cross-tenant compare or promotion work, no customer-facing portfolio board, no AI prioritization, and no generic workflow framework.
|
||||
- **Permanent complexity imported**: One bounded family extension inside the existing governance inbox assembly path, one small convergence contract for arrival and return context across existing pages, and focused unit plus feature coverage.
|
||||
- **Why now**: `docs/product/roadmap.md` still has an open `Decision-Based Operating Foundations` lane, while `docs/product/implementation-ledger.md` identifies decision-surface fragmentation as the highest unspecced operator workflow gap after already-specced compare, commercial, and cleanup packages are removed from the queue.
|
||||
- **Why not local**: Extending only `FindingExceptionsQueue` or only `CustomerReviewWorkspace` would keep the current start-state ambiguity intact and would not establish a single truthful operator entry point.
|
||||
- **Approval class**: Workflow Compression
|
||||
- **Red flags triggered**: One multi-surface convergence flag and one derived-family-extension flag. Defense: the slice extends existing pages and builders, introduces no new persistence, and keeps all workflow state on the existing source surfaces.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- existing canonical workspace route `/admin/governance/inbox`
|
||||
- existing `/admin/findings/my-work`
|
||||
- existing `/admin/findings/intake`
|
||||
- existing `/admin/finding-exceptions/queue`
|
||||
- existing `/admin/reviews/workspace`
|
||||
- existing tenant-scoped finding and review detail routes as drill-through targets only
|
||||
- **Data Ownership**:
|
||||
- `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only persisted truth for their respective families
|
||||
- the convergence layer remains derived from the existing `GovernanceInbox` page and supporting builders; it introduces no new inbox-item table, cache, mirror entity, or workflow state
|
||||
- no new review, exception, or decision summary persistence is introduced
|
||||
- **RBAC**:
|
||||
- workspace membership remains the first boundary for the canonical decision home and all converged launches
|
||||
- non-members and explicit out-of-scope tenant filters remain `404` deny-as-not-found boundaries
|
||||
- in-scope members who can access none of the converged families receive `403`, not a silent empty shell
|
||||
- findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW` and keep claim or assignment semantics on their existing pages, including `Capabilities::TENANT_FINDINGS_ASSIGN`
|
||||
- the exception lane reuses the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE`; this slice must not introduce a second exception capability family
|
||||
- customer-review follow-up and review-pack handoff continue to reuse existing review and pack visibility checks; this slice must not introduce a new review-workspace capability family
|
||||
- the convergence surface stays read-only; all mutations remain enforced on their existing source pages and actions
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped or tenant-prefiltered specialist page, the governance inbox prefilters to that tenant and, when relevant, to the originating family. Clearing filters returns to the workspace-wide decision home instead of preserving a local specialist scope forever.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Broad workspace listings silently omit inaccessible tenants and hidden families from counts, labels, and previews. Explicit tenant or record targets outside visible scope resolve as not found and do not leak family counts or presence hints.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: navigation entry points, decision-home section summaries, action links, queue empty states, back-link continuity, and badge or status reuse
|
||||
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, `CustomerReviewWorkspace`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing action-surface declarations on those pages
|
||||
- **Existing pattern(s) to extend**: the existing governance inbox route and section builder, tenant-prefilter state handling, canonical navigation context, calm empty-state copy on specialist pages, and existing read-first decision surfaces
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, `ActionSurfaceDeclaration`, and existing source-page capability guards
|
||||
- **Why the existing shared path is sufficient or insufficient**: the existing source pages already own the truth and the current governance inbox already owns the canonical route, but the current convergence is insufficient because finding exceptions and explicit customer-review decision continuity still sit outside the calm default operating model
|
||||
- **Allowed deviation and why**: none. The slice should extend the existing governance inbox and source pages instead of introducing a second convergence shell or a generic task framework.
|
||||
- **Consistency impact**: `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, `Open customer review workspace`, and `Back to governance inbox` language must stay consistent across section copy, specialist page affordances, and empty-state recovery actions.
|
||||
- **Review focus**: reviewers must block any implementation that creates a new standalone convergence page, adds local specialist mutations to the governance home, or duplicates specialist proof content on the decision surface.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` start or completion behavior is introduced
|
||||
- **Shared OperationRun UX contract/layer reused**: existing deep-link-only behavior on the already-present stale-operations family remains unchanged
|
||||
- **Delegated start/completion UX behaviors**: `N/A`
|
||||
- **Local surface-owned behavior that remains**: the governance home still decides whether the existing stale-operations section is shown, but this spec does not widen or redefine that contract
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
N/A - no new provider or platform-core boundary is widened. This slice only converges existing operator-facing decision surfaces.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Governance inbox page | yes | Native Filament page plus existing section builder | decision-home summaries, navigation entry points, badge reuse | page, URL-query, derived family state | no | Existing canonical route remains authoritative; no new shell is added |
|
||||
| Findings and exception specialist queues (`MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`) | yes | Native Filament pages | arrival context, return continuity, calm secondary-context copy | page, URL-query, table/session filter restore | no | Remain specialized secondary-context surfaces; no workflow ownership moves here |
|
||||
| Customer review workspace | yes | Native Filament page | review-follow-up handoff, return continuity, calm read-only context | page, URL-query, table/session filter restore | no | Remains the review-consumption surface, not a second governance home |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Governance inbox page | Primary Decision Surface | Operator decides which lane to open next across findings, exceptions, stale operations, alert failures, and review follow-up | visible family counts, top blockers, tenant scope, and one dominant next action per section | specialist queue details, review detail, alert detail, and finding detail after explicit open | Primary because it becomes the single workspace start surface instead of one of several competing starts | Aligns the product with the roadmap's decision-first operating direction | Reduces page hopping before the first action |
|
||||
| Findings and exception specialist queues | Secondary Context | Operator acts inside the chosen lane after the first decision is already made | lane-specific list rows, current tenant scope, and one existing safe next action | record detail, due context, approval context, and deeper diagnostics on existing detail surfaces | Secondary because the lane is chosen at the governance home, not discovered here first | Keeps specialist work inside the existing pages without making them compete as starts | Removes the need to re-evaluate the whole workspace after every lane jump |
|
||||
| Customer review workspace | Secondary Context | Operator verifies the latest customer-safe review state after a governance or review-follow-up cue | latest published review outcome, pack availability, and read-only summary | full review detail and pack download after explicit open | Secondary because it remains a read-only review-consumption surface entered from a governance cue | Preserves the customer-safe review workflow while fitting into the same decision hierarchy | Prevents the review workspace from becoming a competing attention home |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Governance inbox page | operator-MSP | family summary, tenant scope, top blockers, and section CTA | diagnostics stay on the specialist pages | raw payloads and debug detail stay on existing source pages only | `Open attention lane` | raw detail is never rendered on the decision home | the page states the current blocker once and sends the operator to the owning surface for proof |
|
||||
| Findings and exception specialist queues | operator-MSP | lane-specific queue rows, due cues, status, and existing next action | specialist diagnostics and record detail remain available on existing routes | raw/support detail stays behind existing record detail affordances | existing lane-owned action only | any broad workspace summary stays off the specialist pages | specialist pages do not restate the workspace decision-home summary |
|
||||
| Customer review workspace | operator-MSP, customer-read-only | latest customer-safe review status, pack availability, and read-only summary | deeper review diagnostics stay on review detail | raw JSON, provider payloads, and internal support evidence remain hidden or gated on existing detail/download paths | `Open latest review` | support-only raw evidence stays off the workspace surface | review workspace states review truth once and relies on review detail for proof |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the correct existing lane | explicit section CTA or preview-entry CTA | forbidden | section footer links and preview-entry links only | none | `/admin/governance/inbox` | existing specialist routes only | active workspace, optional tenant and family filter | Governance inbox | which lane needs attention now | none |
|
||||
| Findings and exception specialist queues | List / Table / Read-only decision queue | Specialist queue | Open the owning record or use the existing inline safe shortcut | row click and existing specialist inspect path | required | existing lane-owned actions only | existing grouped destructive or risky actions remain on owning detail surfaces | `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/finding-exceptions/queue` | existing finding or exception detail routes | tenant filter, queue view, queue-specific states | Findings / Finding exceptions | lane-specific actionable truth only | none |
|
||||
| Customer review workspace | List / Table / Read-only workspace report | Read-only review consumption | Open the latest published review | clickable row to latest review or explicit download/open action | required | existing read-only actions only | none | `/admin/reviews/workspace` | existing tenant review detail route | tenant filter and review availability | Customer review workspace | latest published review state and pack availability | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Governance inbox page | Workspace operator / MSP operator | Decide which governance lane to open next | Workspace decision hub | What needs attention now across my visible governance lanes? | family counts, top blockers, tenant scope, and section CTA | diagnostics remain on specialist pages | lane urgency, lane ownership, tenant scope | none | Open lane | none |
|
||||
| Findings and exception specialist queues | Workspace operator / MSP operator | Work inside a chosen findings or exception lane | Specialist queue | What in this chosen lane needs action first? | queue rows, due or review cues, owner/assignee context, and current filter state | full record detail and deep diagnostics remain on existing detail routes | workflow status, due/overdue state, review or approval state | existing lane-owned mutations only | inspect record or existing lane action | existing queue-owned risky actions only |
|
||||
| Customer review workspace | Workspace operator / readonly-capable tenant actor | Consume the latest review truth after a review-follow-up cue | Read-only review workspace | What is the latest published review state for this tenant? | latest review outcome, pack availability, and read-only summary | full review detail and pack detail after explicit open | review availability, review freshness, pack availability | none | Open latest review | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes - one bounded convergence contract inside the existing governance inbox assembly and navigation-context seams
|
||||
- **New enum/state/reason family?**: no new persisted family; any added family key remains derived and page-scoped
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: operators still start from multiple repo-real specialist surfaces even though the repo already has enough decision surfaces to support one calmer governance home
|
||||
- **Existing structure is insufficient because**: the current governance inbox does not yet own all remaining high-signal lanes and the specialist pages do not clearly behave as secondary contexts
|
||||
- **Narrowest correct implementation**: extend the existing governance inbox and current specialist pages with one more derived family and shared arrival/return semantics instead of creating a new shell or workflow engine
|
||||
- **Ownership cost**: maintain one more derived section and a small navigation-context convergence layer plus focused tests
|
||||
- **Alternative intentionally rejected**: a new global action center, persisted inbox-item table, and mutation-capable workflow engine were rejected as premature and structurally heavier than the current release truth requires
|
||||
- **Release truth**: current-release workflow compression, not future-release platform speculation
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical extension of the existing governance inbox is preferred over adding a parallel decision surface.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: unit coverage proves section assembly, family ordering, and convergence routing without Filament boot cost; focused feature coverage proves visibility, tenant-filter continuity, return context, and calm secondary-surface behavior on the existing pages
|
||||
- **New or expanded test families**: focused `Unit/Support/GovernanceInbox` coverage plus focused `Feature/Governance`, `Feature/Monitoring`, `Feature/Reviews`, and `Feature/Findings` convergence coverage
|
||||
- **Fixture / helper cost impact**: moderate; tests need workspace membership, visible and hidden tenants, findings, exceptions, review-follow-up states, and review-workspace fixtures, but should reuse existing factories and avoid browser or heavy-governance setup
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: global-context-shell
|
||||
- **Standard-native relief or required special coverage**: special coverage is required for arrival/return context and duplicate-truth prevention across the specialist pages
|
||||
- **Reviewer handoff**: reviewers must confirm that the governance inbox remains the single start surface, counts omit inaccessible families, specialist pages keep one dominant action, and no new mutation lane or persistence appears
|
||||
- **Budget / baseline / trend impact**: low feature-local increase only
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- reuse the existing `GovernanceInbox` page as the canonical workspace decision home
|
||||
- extend the governance inbox with a derived finding-exceptions family sourced from existing queue truth
|
||||
- make customer-review follow-up and customer-review-workspace handoff explicit within the same decision hierarchy
|
||||
- preserve tenant and family arrival context plus truthful return links between the governance home and `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`
|
||||
- keep specialist pages as calm secondary-context surfaces with no duplicate workspace-level blocker summary
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- creating a new global action-center page or dashboard shell
|
||||
- replacing the existing specialist pages or moving their mutations to the governance home
|
||||
- adding a new persisted inbox item, queue state, or workflow engine
|
||||
- changing existing finding, exception, or review lifecycle semantics
|
||||
- cross-tenant compare, promotion, or portfolio execution work
|
||||
- customer-facing portfolio boards or AI-driven prioritization
|
||||
|
||||
## Assumptions
|
||||
|
||||
- the existing `GovernanceInboxSectionBuilder` can accept one more derived family without turning into a generic task engine
|
||||
- current `CanonicalNavigationContext` and tenant-prefilter handling are sufficient to preserve truthful return paths between the decision home and specialist pages
|
||||
- `CustomerReviewWorkspace` remains the correct read-only destination for customer-safe review consumption when a published review detail is not the better direct target
|
||||
|
||||
## Risks
|
||||
|
||||
- implementation could overreach and turn the governance home into a new task engine instead of a routing surface
|
||||
- the finding-exceptions family could leak hidden tenant hints if capability and tenant scoping are not applied before counts and previews are derived
|
||||
- specialist-page convergence could accidentally duplicate blocker language instead of keeping the decision summary on the governance home only
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
- wider dashboard-entry convergence once the governance home proves adoption
|
||||
- portfolio-level decision convergence with cross-tenant compare after Spec 043 implementation
|
||||
- any mutation consolidation only after the read-only convergence hierarchy is proven and remains bounded
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Use one canonical governance home (Priority: P1)
|
||||
|
||||
As a workspace operator, I want one governance home that includes the still-missing exception and review-consumption lanes so I can decide where to work next without choosing between multiple start pages first.
|
||||
|
||||
**Why this priority**: This is the smallest slice that completes the roadmap's decision-first operating direction without inventing new workflow state.
|
||||
|
||||
**Independent Test**: Seed visible findings, finding exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and section CTAs.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the actor can see findings, finding exceptions, and review follow-up for the current workspace, **When** they open the governance inbox, **Then** the page shows those lanes in one canonical surface with one dominant action per section.
|
||||
2. **Given** the actor cannot see finding exceptions, **When** they open the governance inbox, **Then** the exception lane does not appear and no count or empty-state hint implies hidden work exists.
|
||||
3. **Given** the actor applies a tenant-prefilter that hides all current rows, **When** they open the governance inbox, **Then** the page explains that the tenant filter is hiding other visible attention instead of falsely implying the whole workspace is calm.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Move into a specialist lane and back without losing context (Priority: P1)
|
||||
|
||||
As a workspace operator, I want to open a specialist queue or review workspace from the governance home and come back with the same tenant and family context so the governance home becomes my operating anchor instead of a one-off report.
|
||||
|
||||
**Why this priority**: Convergence does not help if every lane jump loses the original decision context.
|
||||
|
||||
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the actor opens `My Findings` or `Findings intake` from the governance inbox, **When** the specialist queue loads, **Then** the existing queue keeps its specialist semantics while exposing a truthful return path to the governance inbox.
|
||||
2. **Given** the actor opens `Finding exceptions` from the governance inbox, **When** the queue loads, **Then** the queue preserves the arrival tenant context and does not become a competing workspace start surface.
|
||||
3. **Given** the actor opens `Customer Review Workspace` from a review-follow-up cue, **When** they inspect the review lane, **Then** the page stays read-only and preserves the governance-home return path.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep specialist surfaces calm and secondary (Priority: P2)
|
||||
|
||||
As a workspace operator, I want specialist queues and the customer review workspace to keep their own lane truth without re-explaining the whole workspace blocker summary so each page stays focused on the action I already chose.
|
||||
|
||||
**Why this priority**: Convergence should reduce attention load, not spread the same summary across more pages.
|
||||
|
||||
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps its lane-specific content while the workspace-level blocker summary remains on the governance home.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the actor opens a specialist queue from the governance home, **When** the specialist page renders, **Then** it shows only lane-specific actionable truth and not a duplicated workspace summary banner.
|
||||
2. **Given** the actor opens the customer review workspace from a review-follow-up cue, **When** the page renders, **Then** it shows customer-safe review truth and not a second governance-home summary card.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- the actor can access the governance inbox but none of the converged specialist families
|
||||
- the requested tenant filter is outside the actor's visible scope
|
||||
- the same tenant has findings, exceptions, and review follow-up at once, but the governance home must still avoid duplicating the same blocker explanation across sections
|
||||
- review follow-up exists for a tenant without a currently published review, requiring the fallback customer-review-workspace destination
|
||||
- the selected tenant is calm for exceptions but not for other families, so the empty-state message must be truthful about what the filter is hiding
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-257-001 Canonical decision home**: The system MUST treat the existing `/admin/governance/inbox` page as the canonical workspace decision home for operator-facing governance attention.
|
||||
- **FR-257-002 Exception-family convergence**: The governance inbox MUST derive a `finding_exceptions` family from existing `FindingExceptionsQueue` truth and render it without adding a new persisted inbox item or queue-state layer.
|
||||
- **FR-257-003 Review-consumption handoff**: Review-follow-up cues on the governance inbox MUST route into the existing review-consumption surfaces, using the latest published review when available and the existing customer review workspace when it is the truthful fallback.
|
||||
- **FR-257-004 Arrival and return continuity**: Launching `My Findings`, `Findings intake`, `Finding exceptions`, or `Customer Review Workspace` from the governance inbox MUST preserve truthful arrival and return context for the current tenant and family scope.
|
||||
- **FR-257-005 Secondary-surface discipline**: Specialist queues and the customer review workspace MUST remain secondary-context surfaces and MUST NOT become competing workspace start surfaces through duplicated workspace summary banners or second primary CTAs.
|
||||
- **FR-257-006 Visibility and omission semantics**: Family counts, section previews, and empty states MUST be derived only from tenants and families the actor can already see through existing capability and entitlement checks.
|
||||
- **FR-257-007 Authorization semantics**: Non-members and out-of-scope tenant targets MUST resolve as `404`, while in-scope members who lack visibility to every converged family MUST receive `403`.
|
||||
- **FR-257-008 No new workflow truth**: The slice MUST NOT add a new inbox-item table, a persisted convergence state, or a new cross-family mutation contract.
|
||||
- **FR-257-009 Source-surface ownership**: Claim, assignment, approval, review, and pack-download behaviors MUST remain on their existing source surfaces and continue to enforce their existing capabilities there.
|
||||
- **FR-257-010 Decision-first disclosure**: The governance inbox MUST show summary and next-action content only; raw payloads, review evidence, and specialist diagnostics MUST remain on the owning specialist or detail surfaces.
|
||||
- **FR-257-011 Duplicate-truth prevention**: The governance inbox and the specialist surfaces MUST NOT restate the same workspace-level blocker or next-action summary as equal-priority content.
|
||||
- **FR-257-012 Read-only review integrity**: The customer review workspace remains read-only in this slice and MUST NOT gain operator-only mutation controls through convergence work.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-257-001**: The convergence layer remains DB-only and derived from existing persisted truth; it MUST NOT add Graph calls, remote calls, queues, or `OperationRun` creation.
|
||||
- **NFR-257-002**: The slice MUST reuse existing Filament and shared UI primitives before any local UI framework or semantic layer is introduced.
|
||||
- **NFR-257-003**: The feature MUST stay within focused `Unit` and `Feature` lanes only; browser or heavy-governance coverage is out of scope unless implementation proves a specific need.
|
||||
|
||||
### UX Requirements
|
||||
|
||||
- **UXR-257-001**: The governance inbox remains the one dominant start surface for the converged lanes.
|
||||
- **UXR-257-002**: Each affected surface has exactly one dominant next action visible by default.
|
||||
- **UXR-257-003**: Specialist surfaces keep lane-specific truth only and rely on explicit return links for workspace context.
|
||||
|
||||
### RBAC / Security Requirements
|
||||
|
||||
- **RBR-257-001**: The slice MUST reuse existing capability registries and MUST NOT introduce raw capability strings or role-name checks.
|
||||
- **RBR-257-002**: Tenant-filter and family-filter state MUST NOT leak inaccessible tenant or family hints through counts, labels, or empty-state copy.
|
||||
|
||||
### Auditability / Observability Requirements
|
||||
|
||||
- **AOR-257-001**: The slice MUST NOT create a new page-view audit stream; existing audit ownership remains on the existing source-surface mutations and downloads.
|
||||
- **AOR-257-002**: Any convergence-specific navigation or UI state remains derived and inspectable through tests rather than new runtime logging.
|
||||
|
||||
### Data / Truth-Source Requirements
|
||||
|
||||
- **DTR-257-001**: `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only source truth inputs for the decision home.
|
||||
- **DTR-257-002**: Any added convergence family key remains derived page state, not persisted domain truth.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- new persistence or workflow-state layers
|
||||
- new operator mutations on the governance home
|
||||
- cross-tenant compare or promotion work
|
||||
- customer-facing portfolio boards or customer portal changes
|
||||
- AI prioritization or recommendation logic
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- the selected operator can open one canonical governance home that includes the missing exception lane and truthful review-consumption handoff without seeing a second competing start surface
|
||||
- specialist pages preserve truthful arrival and return context when opened from the governance home
|
||||
- hidden families and inaccessible tenants do not leak through counts, labels, or empty-state hints
|
||||
- the customer review workspace remains read-only and customer-safe while participating in the same decision hierarchy
|
||||
- no new persistence, workflow state, queue, or runtime mutation surface is introduced
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- operators can explain one default start surface for governance work in the workspace
|
||||
- the specialist pages feel like chosen lanes, not competing homes
|
||||
- implementation can stay bounded to existing page and builder seams with no new framework layer
|
||||
|
||||
## Open Questions
|
||||
|
||||
- none
|
||||
@ -1,189 +0,0 @@
|
||||
---
|
||||
|
||||
description: "Task list for Governance Decision Surface Convergence v1"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Governance Decision Surface Convergence v1
|
||||
|
||||
**Input**: Design documents from `specs/257-governance-decision-convergence/`
|
||||
**Prerequisites**: `specs/257-governance-decision-convergence/plan.md` (required), `specs/257-governance-decision-convergence/spec.md` (required)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage for this read-only convergence slice.
|
||||
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Existing stale-operation links remain unchanged.
|
||||
**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets return `404`; in-scope members with no visible family return `403`. Findings lanes reuse `Capabilities::TENANT_FINDINGS_VIEW`, existing inline safe actions keep their current capability checks such as `Capabilities::TENANT_FINDINGS_ASSIGN`, the exception lane reuses `Capabilities::FINDING_EXCEPTION_APPROVE`, and review handoff reuses existing review and pack visibility checks.
|
||||
**Shared Pattern Reuse**: Reuse `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the existing specialist page action-surface contracts. No new shell, task engine, or persistence layer is allowed.
|
||||
**Organization**: Tasks are grouped by user story so the governance-home extension, navigation convergence, and calm secondary-context rules remain independently testable after the shared groundwork is complete.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceInbox/`, `apps/platform/tests/Feature/Governance/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Monitoring/`, and `apps/platform/tests/Feature/Reviews/` families only.
|
||||
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or generic workflow fixtures.
|
||||
- [x] Planned validation commands cover governance-home assembly, authorization, and arrival/return continuity without widening scope.
|
||||
- [x] The declared surface test profile remains `global-context-shell` because arrival context and tenant-filter continuity are part of the contract.
|
||||
- [x] Any broader action-center, dashboard-entry, or cross-tenant follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden implementation growth.
|
||||
- [x] Test-governance outcome resolves as `keep` for this feature and does not widen the work into a heavier family.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Confirm the bounded convergence slice, the existing governance-home seams, and the reviewer stop conditions before implementation begins.
|
||||
|
||||
- [x] T001 Review the bounded convergence slice in `specs/257-governance-decision-convergence/spec.md` and `specs/257-governance-decision-convergence/plan.md` together with `docs/product/roadmap.md` and `docs/product/implementation-ledger.md`.
|
||||
- [x] T002 [P] Confirm the current governance-home families and summary seams in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
|
||||
- [x] T003 [P] Confirm the specialist-page arrival, return, and filter-state seams in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Extend the shared governance-home and navigation seams that every user story depends on.
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [x] T004 [P] Define or extend the bounded family-aware arrival and return contract inside `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and any minimal supporting helper under `apps/platform/app/Support/GovernanceInbox/` without creating new persistence or a generic workflow framework.
|
||||
- [x] T005 [P] Tighten family omission and access evaluation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` so inaccessible tenants and families disappear before counts are derived and in-scope no-family access resolves as `403`.
|
||||
- [x] T006 Implement the derived `finding_exceptions` family in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` using existing `FindingExceptionsQueue` truth, current queue semantics, and existing capability rules.
|
||||
- [x] T007 Implement truthful review-consumption handoff logic in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` so review-follow-up entries prefer existing latest review detail and fall back to `CustomerReviewWorkspace` only when that is the honest destination.
|
||||
- [x] T008 [P] Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to keep one dominant CTA per section and avoid duplicate workspace-summary cards as the new family is added.
|
||||
|
||||
**Checkpoint**: The governance home can derive the new family, family counts stay capability-safe, and navigation context rules are settled before story-specific work begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Use One Canonical Governance Home (Priority: P1)
|
||||
|
||||
**Goal**: Give the operator one governance home that includes the missing exception and review-consumption lanes without creating a new shell.
|
||||
|
||||
**Independent Test**: Seed visible findings, exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and one dominant CTA per section.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T009 [P] [US1] Extend `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` to cover exception-family inclusion, family ordering, review-workspace fallback, and omission semantics for hidden tenants or families.
|
||||
- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` to cover the visible exception lane, review-consumption handoff summary, tenant-filter empty-state truth, and one dominant CTA per section.
|
||||
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` to cover `404` vs `403` behavior when workspace access exists but all converged family visibility is removed.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T012 [US1] Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the new convergence lane and family-aware summary or empty-state copy.
|
||||
- [x] T013 [US1] Align governance-home copy in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to the stable vocabulary `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, and `Open customer review workspace`.
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when the governance inbox truthfully shows the missing lane and routes to the existing specialist destinations.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Move Into A Specialist Lane And Back (Priority: P1)
|
||||
|
||||
**Goal**: Preserve tenant and family context when the operator opens a specialist page from the governance home and returns.
|
||||
|
||||
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T014 [P] [US2] Add or extend `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` for tenant and family arrival/return continuity across governance-home launches.
|
||||
- [x] T015 [P] [US2] Add or extend `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` for governance-home arrival, preserved tenant context, and truthful `Back to governance inbox` continuity.
|
||||
- [x] T016 [P] [US2] Add or extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for governance-home arrival and return continuity on review-follow-up launches, preferred latest-published-review destination when available, fallback to `CustomerReviewWorkspace` when not, preserved read-only state, and the absence of operator-only mutation controls.
|
||||
- [x] T017 [P] [US2] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php` and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php` for governance-home launch and return continuity on the findings specialist pages.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T018 [US2] Wire governance-home arrival and return context through `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`.
|
||||
- [x] T019 [US2] Expose truthful return affordances without adding a second primary CTA. Repo truth: these native Filament pages expose the affordance through page header actions in `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`; no specialist Blade edits were required.
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when the operator can move between the governance home and the specialist pages without losing truthful context.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep Specialist Surfaces Calm And Secondary (Priority: P2)
|
||||
|
||||
**Goal**: Ensure the specialist pages stay focused on lane-specific truth and do not duplicate the workspace-level summary once convergence context exists.
|
||||
|
||||
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps lane-specific content while the workspace-level blocker summary remains on the governance home only.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to assert duplicate-truth prevention, one dominant default action on each specialist surface, and secondary-context copy when the pages are opened from the governance home.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T021 [US3] Keep lane-specific summaries focused and avoid duplicating workspace-level blocker text. Repo truth: page classes now add secondary return context while existing specialist Blade views stay lane-focused; regression tests assert the governance-home summary text is absent from secondary pages.
|
||||
- [x] T022 [US3] Align action-surface declarations, header affordances, and empty-state recovery actions across `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so the canonical start surface remains obvious.
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when specialist surfaces remain lane-specific secondary contexts instead of competing starts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
|
||||
|
||||
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`.
|
||||
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`.
|
||||
- [x] T025 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
|
||||
- [x] T026 [P] Confirm the slice introduced no new asset registration, no new globally searchable resource, and no new mutation lane; record any bounded follow-up for broader dashboard-entry or portfolio action-center work in the active implementation notes.
|
||||
- [x] T027 [P] Confirm the slice introduced no new Graph or remote calls, no queue or `OperationRun` start path, and no page-view audit or runtime logging stream; record any bounded follow-up if implementation uncovers a structural need outside this slice.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical governance-home truth.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the governance home is not a dead-end report.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because specialist pages must already participate in the convergence flow.
|
||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and establishes the new canonical decision-home behavior.
|
||||
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the new home has truthful workflow continuity.
|
||||
- **US3 (P2)**: independently testable after Phase 2 and refines the specialist pages once the convergence contract exists.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- After the shared foundational contract work in Phase 2 is complete, write the listed Pest coverage first for each user story and make it fail for the intended gap.
|
||||
- Land the shared builder and navigation contract before widening Blade or copy work.
|
||||
- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### User Story 1
|
||||
|
||||
- T009, T010, and T011 can run in parallel before runtime edits begin.
|
||||
- After the family contract settles, T012 and T013 can proceed in parallel because rendering and copy alignment touch different seams.
|
||||
|
||||
### User Story 2
|
||||
|
||||
- T014, T015, T016, and T017 can run in parallel because they cover different destinations in the convergence flow.
|
||||
- After T018 settles the shared navigation contract, T019 can follow to align the Blade affordances.
|
||||
|
||||
### User Story 3
|
||||
|
||||
- T020 can start before implementation finishes because it only captures the expected secondary-context behavior.
|
||||
- T021 and T022 can proceed together once the shared convergence path is stable.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2 together**. The slice becomes product-meaningful only when the governance home shows the missing lanes and the specialist pages preserve truthful return context.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 and US2 together.
|
||||
3. Add US3 secondary-context tightening.
|
||||
4. Finish with focused validation and formatting in Phase 6.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the governance-home family extension and navigation-context contract first.
|
||||
2. Parallelize unit and feature coverage inside each story before runtime edits widen.
|
||||
3. Serialize merges around the governance inbox and specialist Blade views so the decision-home language stays coherent.
|
||||
@ -1,54 +0,0 @@
|
||||
# Preparation Review Checklist: Customer Review Workspace Productization v1
|
||||
|
||||
**Purpose**: Validate repo-fit preparation quality after `spec.md`, `plan.md`, and `tasks.md` are complete
|
||||
**Reviewed**: 2026-04-30
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
**Supporting artifacts**: [plan.md](../plan.md), [tasks.md](../tasks.md), [research.md](../research.md), [data-model.md](../data-model.md), [quickstart.md](../quickstart.md), [customer-review-productization.openapi.yaml](../contracts/customer-review-productization.openapi.yaml)
|
||||
|
||||
## Candidate Fit
|
||||
|
||||
- [x] The selected candidate still matches the active P0 queue in `docs/product/spec-candidates.md`, the current priority order in `docs/product/roadmap.md`, and the open-gap wording in `docs/product/implementation-ledger.md`
|
||||
- [x] Existing `specs/` coverage was checked so this package stays a new productization follow-up rather than a duplicate of Specs 249, 253, 254, 255, or 257
|
||||
- [x] The scope stays on the customer-review productization delta over the existing workspace and released-review detail flow instead of reopening review foundations
|
||||
- [x] Broader baseline/control overlays and management-packaging follow-through are explicitly deferred rather than hidden inside this slice
|
||||
|
||||
## Constitution Fit
|
||||
|
||||
- [x] The package stays on the existing Filament v5 + Livewire v4 admin plane and does not introduce panel/provider-registration work beyond the current `bootstrap/providers.php` truth
|
||||
- [x] No new persistence, customer identity plane, portal shell, authoring flow, publication engine, remediation flow, or destructive action surface is introduced
|
||||
- [x] Workspace/tenant isolation and capability-first RBAC remain explicit, including `404` for non-members and optional capability gating only for secondary access paths
|
||||
- [x] One dominant safe action per changed surface is explicitly described, with secondary proof affordances demoted out of peer header-action status
|
||||
- [x] Global-search safety is preserved without introducing a new searchable resource or widening existing review/evidence discovery across tenant boundaries
|
||||
- [x] Asset strategy remains unchanged; if later implementation unexpectedly registers assets, deployment still uses the existing `cd apps/platform && php artisan filament:assets` step
|
||||
|
||||
## Artifact Consistency
|
||||
|
||||
- [x] `spec.md`, `plan.md`, and `tasks.md` all target the same workspace-summary plus released-review-detail follow-up
|
||||
- [x] The likely repo surfaces and plan structure match the current repository layout, including `apps/platform/lang` rather than a fictional app-local language directory
|
||||
- [x] Tasks directly cover RBAC, auditability, disclosure hierarchy, localization, access/unavailable states, and global-search safety
|
||||
- [x] Supporting artifacts exist, no unresolved template markers remain, and the package stays implementation-ready without touching application code
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Validation lanes remain explicitly bounded to `confidence` plus one existing `browser` smoke
|
||||
- [x] The package reuses the existing reviews test family instead of creating a new heavy-governance or browser family
|
||||
- [x] Reviewer proof commands remain explicit and minimal for the touched workspace, detail, pack, and proof surfaces
|
||||
- [x] The close-out path records the review outcome, guardrail status, and any `document-in-feature` vs `follow-up-spec` decision inside the spec package
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed after artifact alignment on 2026-04-30.
|
||||
- This repository's preparation artifacts are intentionally implementation-oriented, so concrete routes, classes, affected surfaces, and validation commands are expected rather than treated as leakage.
|
||||
- No application implementation was performed while preparing or reviewing this package.
|
||||
- Implementation close-out on 2026-04-30 passed the focused feature checks, bounded browser smoke, and Pint. Audit gaps were handled with bounded additive action IDs for workspace entry and proof-open events; global-search and asset strategy remained unchanged.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome**: `keep`
|
||||
- **Reason**: The package remains the narrow customer-review productization follow-up, explicitly records the baseline/control deferral, aligns the detail-page action hierarchy, and adds direct task coverage for global-search safety.
|
||||
- **Workflow result**: Ready for `/speckit.implement` after this preparation review.
|
||||
|
||||
## Implementation Outcome
|
||||
|
||||
- **Outcome**: `implemented`
|
||||
- **Workflow result**: Ready for manual review after the implementation loop. No confirmed in-scope findings remain after the focused confidence checks, browser smoke, formatting, and post-implementation analysis.
|
||||
@ -1,299 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Customer Review Workspace Productization v1 (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual contract for the customer-safe productization follow-up in Spec 258.
|
||||
|
||||
NOTE: These paths describe existing admin and tenant-scoped routes reused by
|
||||
the implementation. The schemas document expected derived page/view behavior
|
||||
for planning purposes only; they do not require a new public REST API.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/admin/reviews/workspace:
|
||||
get:
|
||||
summary: View the productized customer review workspace
|
||||
description: |
|
||||
Existing canonical admin-plane workspace page for customer-safe review
|
||||
consumption. The route stays read-only and reuses current tenant review,
|
||||
finding, evidence, review-pack, localization, RBAC, and audit truth.
|
||||
parameters:
|
||||
- in: query
|
||||
name: tenant
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: |
|
||||
Optional tenant prefilter using the existing tenant id or external id
|
||||
pattern already accepted by the workspace page.
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerReviewWorkspacePageModel'
|
||||
'404':
|
||||
description: Not found for non-members, actors without entitled tenants, or explicit out-of-scope tenant targeting
|
||||
|
||||
/admin/t/{tenant}/reviews/{review}:
|
||||
get:
|
||||
summary: Open the released review detail from the customer review workspace
|
||||
description: |
|
||||
Existing tenant-scoped released-review detail route reused as the
|
||||
secondary context surface from the workspace page. The customer-workspace
|
||||
flow uses the existing `customer_workspace=1` query flag to keep the
|
||||
detail read-only and customer-safe.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: path
|
||||
name: review
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: customer_workspace
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
description: Existing query-context flag that suppresses operator lifecycle actions on the detail surface.
|
||||
responses:
|
||||
'200':
|
||||
description: Released review detail rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerReviewDetailModel'
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the record-level review permission
|
||||
'404':
|
||||
description: Not found for non-members, tenant mismatches, or out-of-scope review targets
|
||||
|
||||
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
|
||||
get:
|
||||
summary: Open an evidence proof route from the customer review flow
|
||||
description: |
|
||||
Existing tenant-scoped evidence detail route reused only when the actor
|
||||
explicitly asks for proof and has the required capability.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: path
|
||||
name: evidenceSnapshot
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: source_surface
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Optional source-surface metadata if proof access is audited through the shared audit pipeline.
|
||||
responses:
|
||||
'200':
|
||||
description: Evidence proof detail rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing evidence capability
|
||||
'404':
|
||||
description: Not found for non-members, mismatched tenant scope, or unavailable proof targets
|
||||
|
||||
/admin/review-packs/{reviewPack}/download:
|
||||
get:
|
||||
summary: Download the current review pack
|
||||
description: |
|
||||
Existing signed download route reused by the productized customer review
|
||||
flow. The pack must already exist, be ready, and not be expired.
|
||||
parameters:
|
||||
- in: path
|
||||
name: reviewPack
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: source_surface
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Existing download metadata hook used by the shared audit path.
|
||||
responses:
|
||||
'200':
|
||||
description: Review pack download stream
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'403':
|
||||
description: Forbidden because of missing signature or invalid signed URL
|
||||
'404':
|
||||
description: Review pack not found, not ready, expired, or out of accessible tenant scope
|
||||
|
||||
components:
|
||||
schemas:
|
||||
CustomerReviewWorkspacePageModel:
|
||||
type: object
|
||||
required:
|
||||
- workspace_id
|
||||
- entries
|
||||
properties:
|
||||
workspace_id:
|
||||
type: integer
|
||||
tenant_filter_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CustomerReviewWorkspaceEntry'
|
||||
empty_state_message:
|
||||
type: string
|
||||
nullable: true
|
||||
audit_expectation:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Planning-only note describing whether workspace-open auditing is
|
||||
already covered or requires a bounded shared-audit extension.
|
||||
|
||||
CustomerReviewWorkspaceEntry:
|
||||
type: object
|
||||
required:
|
||||
- tenant_id
|
||||
- tenant_name
|
||||
- review_access
|
||||
- review_pack_access
|
||||
- evidence_proof_access
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
tenant_name:
|
||||
type: string
|
||||
latest_published_review_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
latest_review_published_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
outcome_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
findings_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
accepted_risk_accountability_summary:
|
||||
$ref: '#/components/schemas/AcceptedRiskAccountabilitySummary'
|
||||
review_access:
|
||||
$ref: '#/components/schemas/AccessState'
|
||||
review_pack_access:
|
||||
$ref: '#/components/schemas/AccessState'
|
||||
evidence_proof_access:
|
||||
$ref: '#/components/schemas/AccessState'
|
||||
redaction_note:
|
||||
type: string
|
||||
nullable: true
|
||||
absence_note:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
CustomerReviewDetailModel:
|
||||
type: object
|
||||
required:
|
||||
- review_id
|
||||
- tenant_id
|
||||
- launched_from_customer_workspace
|
||||
- operator_actions_hidden
|
||||
properties:
|
||||
review_id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
launched_from_customer_workspace:
|
||||
type: boolean
|
||||
operator_actions_hidden:
|
||||
type: boolean
|
||||
narrative_outcome_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
findings_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
accepted_risk_accountability_summary:
|
||||
$ref: '#/components/schemas/AcceptedRiskAccountabilitySummary'
|
||||
evidence_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
review_pack_access:
|
||||
$ref: '#/components/schemas/AccessState'
|
||||
evidence_proof_access:
|
||||
$ref: '#/components/schemas/AccessState'
|
||||
secondary_diagnostics_collapsed:
|
||||
type: boolean
|
||||
nullable: true
|
||||
|
||||
AcceptedRiskAccountabilitySummary:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
summary_text:
|
||||
type: string
|
||||
accountable_party:
|
||||
type: string
|
||||
nullable: true
|
||||
decision_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
review_due_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completeness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
AccessState:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- available
|
||||
- absent
|
||||
- unavailable
|
||||
- expired
|
||||
- redacted
|
||||
- partial
|
||||
message:
|
||||
type: string
|
||||
nullable: true
|
||||
url:
|
||||
type: string
|
||||
nullable: true
|
||||
audit_action_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Existing or bounded-additive shared audit action id for the explicit access moment.
|
||||
@ -1,273 +0,0 @@
|
||||
# Data Model — Customer Review Workspace Productization v1
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
No new persisted tables, projections, or customer-review entities are required for this follow-up. The feature reuses current tenant-owned review, finding-exception, evidence, review-pack, membership, and audit truth, then tightens the derived workspace and detail presentation contracts.
|
||||
|
||||
## Persisted Truth Reused
|
||||
|
||||
### Workspace / Tenant Entitlement Context
|
||||
|
||||
**Purpose**: Establish the active workspace boundary and the entitled tenant set before any workspace rows, proof links, or review detail routes are composed.
|
||||
|
||||
**Persisted carriers**:
|
||||
- existing workspace membership records
|
||||
- existing tenant membership pivot rows and role assignments
|
||||
- existing capability registry and role-capability map
|
||||
|
||||
**Relevant fields / contracts**:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- tenant membership role
|
||||
- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php)
|
||||
- current workspace and remembered tenant context from the existing workspace context/session model
|
||||
|
||||
**Validation rules**:
|
||||
- current actor must be a member of the current workspace or the route resolves as not found
|
||||
- workspace rows and explicit tenant filters may only resolve for entitled tenants in that current workspace
|
||||
- out-of-scope tenant targets remain `404` and must not leak draft/review existence
|
||||
|
||||
### TenantReview
|
||||
|
||||
**Purpose**: Canonical source for the released governance record, current outcome summary, findings summary, accepted-risk summary, proof pointers, and review-detail inspect target.
|
||||
|
||||
**Persisted carrier**: existing `tenant_reviews` rows via [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php)
|
||||
|
||||
**Relevant fields / relationships**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `status`
|
||||
- `generated_at`
|
||||
- `published_at`
|
||||
- `summary`
|
||||
- `evidence_snapshot_id`
|
||||
- `current_export_review_pack_id`
|
||||
- `published_by_user_id`
|
||||
- `tenant`
|
||||
- `evidenceSnapshot`
|
||||
- `currentExportReviewPack`
|
||||
- `sections`
|
||||
|
||||
**Embedded summary payload currently reused**:
|
||||
- `finding_count`
|
||||
- `finding_outcomes`
|
||||
- `risk_acceptance.status_marked_count`
|
||||
- `risk_acceptance.valid_governed_count`
|
||||
- `risk_acceptance.warning_count`
|
||||
- `publish_blockers`
|
||||
|
||||
**Validation / usage rules**:
|
||||
- the workspace default path continues to use the latest published review per entitled tenant only
|
||||
- internal-only review states remain off the customer-safe default path
|
||||
- the customer-workspace drilldown stays on the existing review detail route under the existing query-context flag
|
||||
- productization may refine how summary data is explained, but it must not move that truth into a new stored model
|
||||
|
||||
### FindingException
|
||||
|
||||
**Purpose**: Existing accepted-risk and accountability truth used to explain who accepted risk, why it is on record, and whether it needs follow-up.
|
||||
|
||||
**Persisted carrier**: existing `finding_exceptions` rows via [../../apps/platform/app/Models/FindingException.php](../../apps/platform/app/Models/FindingException.php)
|
||||
|
||||
**Relevant fields / relationships**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `finding_id`
|
||||
- `status`
|
||||
- `current_validity_state`
|
||||
- `requested_at`
|
||||
- `approved_at`
|
||||
- `effective_from`
|
||||
- `expires_at`
|
||||
- `review_due_at`
|
||||
- `owner_user_id`
|
||||
- `approved_by_user_id`
|
||||
- `current_decision_id`
|
||||
- `evidence_summary`
|
||||
- `owner`
|
||||
- `approver`
|
||||
- `currentDecision`
|
||||
- `evidenceReferences`
|
||||
|
||||
**Validation / usage rules**:
|
||||
- accountability summaries should derive from existing owner/approver/current-decision truth where present
|
||||
- missing accountable-person or accountable-role truth must surface as partial/unavailable disclosure, not fabricated customer-safe copy
|
||||
- accepted-risk visibility remains read-only in this slice; no edit, renew, revoke, or approval behavior moves into the customer-safe path
|
||||
|
||||
### EvidenceSnapshot
|
||||
|
||||
**Purpose**: Existing proof artifact for evidence freshness, completeness, and optional supporting detail reached only after explicit user intent.
|
||||
|
||||
**Persisted carrier**: existing `evidence_snapshots` rows via [../../apps/platform/app/Models/EvidenceSnapshot.php](../../apps/platform/app/Models/EvidenceSnapshot.php)
|
||||
|
||||
**Relevant fields / relationships**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `status`
|
||||
- `completeness_state`
|
||||
- `generated_at`
|
||||
- `expires_at`
|
||||
- `summary`
|
||||
- `items`
|
||||
|
||||
**Validation / usage rules**:
|
||||
- evidence proof remains optional, lower-priority, and capability-gated by the current evidence-view path
|
||||
- raw payloads and unrestricted diagnostics remain out of the default-visible workspace and review detail path
|
||||
- if implementation adds explicit proof-access auditing, it should stay on the shared audit pipeline
|
||||
|
||||
### ReviewPack
|
||||
|
||||
**Purpose**: Existing packaged governance artifact for current downloadable review output.
|
||||
|
||||
**Persisted carrier**: existing `review_packs` rows via [../../apps/platform/app/Models/ReviewPack.php](../../apps/platform/app/Models/ReviewPack.php)
|
||||
|
||||
**Relevant fields / relationships**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `tenant_review_id`
|
||||
- `status`
|
||||
- `generated_at`
|
||||
- `expires_at`
|
||||
- `summary`
|
||||
- `file_path`
|
||||
- `file_disk`
|
||||
- `sha256`
|
||||
- `operation_run_id`
|
||||
- `tenantReview`
|
||||
- `evidenceSnapshot`
|
||||
|
||||
**Validation / usage rules**:
|
||||
- only current ready, unexpired packs remain available in the customer-safe flow
|
||||
- review-pack access continues to use the existing signed download route and current capability check
|
||||
- the feature must not surface generate/regenerate flows, even when a pack is unavailable
|
||||
|
||||
### Audit Log Event Family
|
||||
|
||||
**Purpose**: Existing auditable truth for explicit customer-review consumption moments.
|
||||
|
||||
**Persisted carrier**: existing `audit_logs` rows via [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php)
|
||||
|
||||
**Relevant current action IDs**:
|
||||
- `tenant_review.opened`
|
||||
- `review_pack.downloaded`
|
||||
|
||||
**Potential bounded extensions only if implementation confirms a gap**:
|
||||
- workspace access open event for the customer review workspace route
|
||||
- evidence proof access open event for proof routes launched from the customer review flow
|
||||
|
||||
**Validation / usage rules**:
|
||||
- auditable access remains on the shared audit path only
|
||||
- no new audit store or mirror analytics stream is justified
|
||||
- workspace, tenant, source-surface, and artifact identifiers stay in stable audit metadata when a new access moment is added
|
||||
|
||||
## Derived Read Models
|
||||
|
||||
### CustomerReviewWorkspaceEntry
|
||||
|
||||
**Purpose**: Derived row-level presentation contract for one entitled tenant on the existing workspace page.
|
||||
|
||||
**Persistence**: none; computed at request time
|
||||
|
||||
**Fields**:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `tenant_name`
|
||||
- `latest_published_review_id` (nullable)
|
||||
- `latest_review_published_at` (nullable)
|
||||
- `outcome_summary`
|
||||
- `findings_summary`
|
||||
- `accepted_risk_accountability_summary`
|
||||
- `evidence_proof_state`
|
||||
- `review_pack_state`
|
||||
- `primary_review_url` (nullable)
|
||||
- `review_pack_download_url` (nullable)
|
||||
- `proof_detail_url` (nullable)
|
||||
- `absence_note` (nullable)
|
||||
- `unavailable_note` (nullable)
|
||||
- `redaction_note` (nullable)
|
||||
|
||||
**Derivation rules**:
|
||||
- exactly one derived entry exists per entitled tenant visible in the current workspace scope
|
||||
- if a published review exists, the entry derives its customer-safe summary from that released record only
|
||||
- if no published review exists, the entry surfaces an explicit absence note and omits deep links that depend on a released review
|
||||
- if optional proof or pack access is blocked by capability or artifact state, the review remains readable while the secondary path becomes explicitly unavailable
|
||||
|
||||
**Validation rules**:
|
||||
- entries may only be built for entitled tenants in the active workspace
|
||||
- `review_pack_download_url` is present only when a current pack exists and the actor can consume it
|
||||
- `proof_detail_url` is present only when the actor can open the proof route
|
||||
- raw payloads, unrestricted diagnostics, provider IDs, and copied support context are never part of the default entry model
|
||||
|
||||
### CustomerReviewDetailPresentation
|
||||
|
||||
**Purpose**: Derived section contract for the existing released-review detail page when it is launched from the customer review workspace.
|
||||
|
||||
**Persistence**: none; computed from the existing review record and current query-context flag
|
||||
|
||||
**Fields**:
|
||||
- `review_id`
|
||||
- `tenant_id`
|
||||
- `launched_from_customer_workspace` (boolean)
|
||||
- `narrative_outcome_summary`
|
||||
- `findings_summary`
|
||||
- `accepted_risk_accountability_summary`
|
||||
- `evidence_summary`
|
||||
- `proof_pointer_state`
|
||||
- `review_pack_state`
|
||||
- `operator_actions_hidden` (boolean)
|
||||
- `secondary_diagnostics_collapsed` (boolean)
|
||||
|
||||
**Derivation rules**:
|
||||
- only the existing `customer_workspace` query context activates this productized secondary presentation mode
|
||||
- the detail remains readable even when optional pack/evidence capabilities are absent
|
||||
- management actions remain suppressed in this context
|
||||
|
||||
**Validation rules**:
|
||||
- this derived model must not create a second review detail route or a second stored summary object
|
||||
- secondary proof and support detail remain lower-priority than the narrative governance record
|
||||
- duplicate equal-priority summary blocks between workspace and detail should be removed or reduced
|
||||
|
||||
### CustomerReviewPageState
|
||||
|
||||
**Purpose**: Request/query/session-backed page state already required for tenant-prefilter, remembered scope, and launch context continuity.
|
||||
|
||||
**Persistence**: request, URL query, and existing session-backed table state only
|
||||
|
||||
**Fields**:
|
||||
- `tenant` prefilter (nullable)
|
||||
- remembered tenant id in workspace context (nullable)
|
||||
- `customer_workspace` detail context flag (boolean on the detail route)
|
||||
- navigation context metadata when launched from other canonical pages (nullable)
|
||||
|
||||
**Validation rules**:
|
||||
- explicit tenant prefilters must resolve to an entitled tenant or the request fails as not found
|
||||
- any state required after Livewire interaction must remain hydrated via public/query/session-backed state
|
||||
- no private property may own the control path for disclosure or filter restore
|
||||
|
||||
## Derived Disclosure States
|
||||
|
||||
This feature introduces no new persisted lifecycle or enum family. It does require explicit derived disclosure outcomes on existing surfaces:
|
||||
|
||||
- `available`: the actor can open the review/proof/pack path now
|
||||
- `absent`: the underlying released artifact does not exist for this tenant yet
|
||||
- `unavailable`: the artifact exists conceptually but is not currently consumable because of capability, readiness, or redaction limits
|
||||
- `expired`: the artifact exists and was previously consumable, but time-based or release-lifecycle rules now block access while the surface still needs to explain why
|
||||
- `redacted`: the route or surface remains visible, but protected details stay hidden behind existing redaction rules
|
||||
- `partial`: the governance record is readable, but accountability/proof detail is incomplete in current source truth
|
||||
|
||||
These remain derived page semantics only and must not become stored status families.
|
||||
|
||||
## State Transition Summary
|
||||
|
||||
No new persisted lifecycle is added. Only derived surface transitions are expected:
|
||||
|
||||
- workspace open -> entitled tenant rows or truthful empty/absence state
|
||||
- remembered tenant or explicit tenant query -> tenant-prefiltered workspace view
|
||||
- workspace row with released review -> existing review detail route available
|
||||
- workspace row without released review -> explicit absence state and no review-open action
|
||||
- released review detail with optional proof/pack capability missing -> review remains readable and secondary path becomes unavailable
|
||||
- released review detail with an expired pack/proof artifact -> review remains readable and secondary path becomes explicitly expired
|
||||
- explicit workspace/review/proof/pack consumption -> shared audit event when covered by the current audit registry or a bounded additive action ID
|
||||
@ -1,301 +0,0 @@
|
||||
# Implementation Plan: Customer Review Workspace Productization v1
|
||||
|
||||
**Branch**: `258-customer-review-productization` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Productize the existing customer review workspace into a calmer, customer-safe governance-of-record surface by tightening the current [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) page and the existing released-review drilldown in [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php). The implementation should reuse current review, finding, accepted-risk, evidence, review-pack, localization, RBAC, and audit truth rather than adding a portal shell, new persistence, or a second presentation framework.
|
||||
|
||||
This is a bounded follow-up to Spec 249, not a fresh workspace foundation. Filament remains on Livewire v4 under v5, panel-provider registration stays where it is today in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel or provider work is planned, no new globally searchable scope is introduced, no destructive actions are in scope, and no new asset registration strategy is expected.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack services, capability helpers, localization copy, and workspace audit infrastructure
|
||||
**Storage**: PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence planned
|
||||
**Testing**: Pest v4 feature coverage plus one bounded browser smoke slice on the existing workspace flow
|
||||
**Validation Lanes**: confidence, browser
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, existing admin plane only (`/admin` plus existing tenant-scoped `/admin/t/{tenant}` reuse)
|
||||
**Project Type**: Web application (Laravel monolith with Filament pages/resources)
|
||||
**Performance Goals**: keep workspace and detail rendering DB-only and scope-safe, reuse eager-loaded existing review/pack/evidence relations, and avoid any new Graph calls, queue starts, or heavy asset work on render
|
||||
**Constraints**: no new page shell, no new persistence, no review publishing engine, no remediation flow, no new customer identity plane, no new global-search scope, no new heavy asset strategy, and no destructive action exposure
|
||||
**Scale/Scope**: 1 existing workspace page, 1 existing released-review detail page, 2 existing proof/detail resources, 2 localization files, 1 shared audit pipeline, and the existing `tests/Feature/Reviews/*` plus 1 existing browser smoke
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) for calmer workspace copy, derived summary semantics, explicit access or absence states, and table action hierarchy.
|
||||
- [../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php](../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php) for the page intro and disclosure framing.
|
||||
- [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) for the released-review secondary-context contract, customer-workspace query-flag behavior, and audit handoff.
|
||||
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php) for existing released-review detail sections, proof links, and current pack/evidence affordances.
|
||||
- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) for workspace membership, entitled tenant scoping, and latest-published review composition.
|
||||
- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) plus `SurfaceCompressionContext` for outcome, freshness, and publication wording already used by review and pack surfaces.
|
||||
- [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php](../../apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php), and [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) for current pack availability, safe deep links, and signed download auditing.
|
||||
- [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php) for proof-pointer routing and explicit unavailable states.
|
||||
- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for auditable workspace access, review access, proof access, and pack download behavior through the shared audit path.
|
||||
- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php) and [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) for capability-first RBAC and workspace/tenant-safe omission rules.
|
||||
- [../../apps/platform/lang/en/localization.php](../../apps/platform/lang/en/localization.php) and [../../apps/platform/lang/de/localization.php](../../apps/platform/lang/de/localization.php) for calmer customer-safe wording without introducing a second vocabulary system.
|
||||
- [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php), and [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) for the bounded proof surface already in the repo.
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- Keep [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) as the canonical customer-safe landing surface. This follow-up productizes the existing page instead of adding a new page class, a new Resource, or a new panel.
|
||||
- Keep [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) as the secondary context surface reached from the workspace via the existing `customer_workspace` query flag. The drilldown should deepen the governance record, not reopen operator lifecycle controls.
|
||||
- Preserve the current Livewire-safe filter and remembered-tenant behavior already implemented on the workspace page. Any added state must remain public, query-backed, or session-backed; no private state should control postback-critical disclosure.
|
||||
- Retain one dominant next action per surface. On the workspace page that remains `Open released review`; pack download or proof routes stay secondary and capability-gated. On the detail page, review-pack access remains the dominant safe action while evidence proof stays lower priority.
|
||||
- Keep the entire feature in native Filament primitives plus the existing review/evidence shared seams. No custom shell, no heavy asset registration, and no new global-search scope are planned.
|
||||
|
||||
## RBAC / Policy Fit
|
||||
|
||||
- Workspace membership remains the first isolation boundary through the existing workspace context and `TenantReviewRegisterService::canAccessWorkspace(...)` path.
|
||||
- Entitled-tenant composition remains capability-first: page entry and rows continue to derive from the current role-capability map and `TENANT_REVIEW_VIEW` path rather than new customer-only roles or raw role-string checks.
|
||||
- Proof pointers and safe secondary actions continue to reuse existing gates: `REVIEW_PACK_VIEW` for current pack download, `EVIDENCE_VIEW` for proof detail, `TENANT_FINDINGS_VIEW` and `FINDING_EXCEPTION_VIEW` for deeper review content when surfaced, and existing policy checks on review/evidence resources.
|
||||
- Non-members and explicit out-of-scope tenant targets remain `404`. Member actors who can read the review surface but lack an optional deep-link capability should still see the review with an explicit unavailable state for that optional path.
|
||||
- No new panel, tenant plane, customer portal plane, or identity model is introduced. This remains an admin-plane follow-up only.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- Reuse [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for all auditable moments. No new audit store, no telemetry sidecar, and no page-local logging subsystem are justified.
|
||||
- Current review access from the customer-workspace drilldown already logs `TenantReviewOpened` with `source_surface=customer_review_workspace`, and current pack downloads already log `ReviewPackDownloaded` through the signed download route.
|
||||
- Planning should explicitly account for two remaining audit moments required by this spec: workspace access itself and evidence-summary or proof access when the actor opens an explicit proof route from the customer-safe flow. If those moments are not already covered, the narrowest acceptable change is additive stable action IDs on the existing audit pipeline.
|
||||
- Passive rendering should still avoid noisy event spam. The auditable boundary is explicit workspace entry or explicit artifact/proof consumption, not every Livewire repaint.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- Keep the base row query on the existing `customerWorkspaceTenantQuery(...)` seam in [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php). This feature productizes the presentation contract over that query; it does not replace it with a new projection.
|
||||
- Findings and accepted-risk summaries remain derived from the current `TenantReview.summary` payload already used by the workspace page, including `finding_count`, `finding_outcomes`, and `risk_acceptance` substructures.
|
||||
- Accepted-risk accountability follow-through should reuse existing `FindingException` and current decision truth where that data already exists. Missing accountable-person or accountable-role truth must surface as explicit partial or unavailable disclosure, not invented copy.
|
||||
- Evidence proof semantics should stay anchored to existing `EvidenceSnapshot`, related context entries, and `ArtifactTruthPresenter` output. The feature may reorder or reword disclosure, but it should not create a second evidence summary model.
|
||||
- Access, absence, unavailable, expired, and redacted states remain derived UI or route-state semantics only. They must not become new persisted lifecycle fields or a new presentation enum family.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament
|
||||
- **Shared-family relevance**: status messaging, evidence/report viewers, action links, navigation entry points, access-state messaging, and review-pack access affordances
|
||||
- **State layers in scope**: page, detail, URL-query, table/session restore
|
||||
- **Audience modes in scope**: customer/read-only, customer-admin, auditor-read-only, operator-MSP
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
|
||||
- **Raw/support gating plan**: collapsed and capability-gated on reused detail/proof routes only
|
||||
- **One-primary-action / duplicate-truth control**: `Open released review` remains the workspace primary action; review-pack access is the detail primary action; equal-priority duplicate summary blocks across workspace and detail are out of scope
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament, shared-detail-family
|
||||
- **Required tests or manual smoke**: functional-core, state-contract, bounded-browser-smoke
|
||||
- **Exception path and spread control**: none planned; any new presenter/taxonomy/customer-shell proposal becomes exception-required drift
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `CustomerReviewWorkspace`, `ViewTenantReview`, `TenantReviewResource`, `ReviewPackResource`, `EvidenceSnapshotResource`, `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `ReviewPackDownloadController`, `WorkspaceAuditLogger`, `AuditActionId`, and review localization copy
|
||||
- **Shared abstractions reused**: `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, existing resource URL helpers, existing action-surface declarations, `ReviewPackService`, and the shared audit logger
|
||||
- **New abstraction introduced? why?**: none planned. If implementation discovers a small copy or disclosure helper is needed, it should stay inside the existing review surface family instead of becoming a new reusable framework
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the repo already has the page, detail route, truth envelopes, pack download path, and audit seams; what is insufficient today is the product contract over those seams, not the underlying domain model
|
||||
- **Bounded deviation / spread control**: none planned. This slice should tighten the current path rather than add a parallel customer-review language, mirror page, or publication layer
|
||||
|
||||
## 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**: read-only workspace and detail rendering only; any existing operation-run links remain secondary diagnostics on reused detail surfaces
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: existing workspace, tenant, review, evidence, risk acceptance, review pack, and audit vocabulary only
|
||||
- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `review`, `evidence`, `review pack`, `accepted risk`, `proof`, and existing artifact-truth wording
|
||||
- **Retained provider-specific semantics and why**: none new
|
||||
- **Bounded extraction or follow-up path**: `N/A`
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation preparation continues. Re-check after Phase 1 design artifacts.*
|
||||
|
||||
- Inventory-first / snapshot truth: PASS. The slice consumes existing review, pack, and evidence artifacts as read-only truth.
|
||||
- Read/write separation: PASS. No new create, publish, regenerate, refresh, remediation, or destructive flow is introduced.
|
||||
- Graph contract path: PASS. No new Graph work or provider contract work is part of this slice.
|
||||
- Deterministic capabilities: PASS. Existing capability registries and role maps remain authoritative.
|
||||
- Workspace and tenant isolation: PASS. Workspace membership and tenant entitlement remain non-negotiable `404` boundaries.
|
||||
- RBAC-UX plane separation: PASS. Everything stays in the existing `/admin` plane and current tenant-scoped detail routes.
|
||||
- Destructive confirmation standard: PASS by non-use. Destructive actions are out of scope.
|
||||
- Global search safety: PASS. No new globally searchable resource or search scope is added; any mention of search remains tenant-safe reuse only.
|
||||
- OperationRun / Ops-UX: PASS by non-use. The productization slice starts no runs and changes no run lifecycle UX.
|
||||
- Data minimization: PASS. Default-visible content remains decision-first; raw payloads and unrestricted diagnostics stay gated.
|
||||
- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused feature coverage plus one bounded browser smoke.
|
||||
- Proportionality / no premature abstraction: PASS. The feature productizes existing surfaces instead of adding persistence, a shell, or a second presenter framework.
|
||||
- Persisted truth (PERSIST-001): PASS. No new table, artifact, or cache is planned.
|
||||
- Behavioral state (STATE-001): PASS. Access, absence, unavailable, expired, and redacted conditions remain derived presentation semantics.
|
||||
- UI semantics / shared pattern first / Filament-native UI: PASS. Native Filament pages/resources and existing truth abstractions remain the default path.
|
||||
- Provider boundary (PROV-001): PASS. No provider/platform seam widens.
|
||||
- Filament / Laravel planning contract: PASS. Filament v5 stays on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel/provider work is planned, no new global-search scope is created, and asset handling stays unchanged (`cd apps/platform && php artisan filament:assets` remains deploy-only if future registered assets are ever added).
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
- The feature stays in the existing admin plane and current workspace/tenant membership model.
|
||||
- The canonical entry surface remains the existing customer review workspace, not a new shell.
|
||||
- Existing truth seams are sufficient if implementation resists adding a mirror presenter or publication engine.
|
||||
|
||||
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/customer-review-productization.openapi.yaml](contracts/customer-review-productization.openapi.yaml)).
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature for workspace rows, access/absence/unavailable states, navigation context, pack access, and audit metadata; Browser for one bounded end-to-end calm disclosure path on the existing workspace handoff
|
||||
- **Affected validation lanes**: confidence, browser
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the repo already has the exact workspace feature family and a single smoke harness; expanding those files is cheaper and more honest than adding new browser families or generalized helpers
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, entitled-tenant, published review, finding-exception, evidence snapshot, review pack, and audit fixtures
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any new helper should stay explicit and inside the reviews family
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none beyond the already-existing single browser smoke
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief on the workspace page, shared-detail-family coverage on the released-review handoff
|
||||
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify customer-safe default visibility, verify `404` on out-of-scope tenant targeting, verify optional proof paths show explicit unavailable states instead of leaking content, and verify audit metadata stays on the shared logger path
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond small feature-local assertions in the existing reviews suite
|
||||
- **Review-stop questions**: lane fit, hidden fixture growth, browser sprawl, duplicate-truth regressions, audit-gap drift
|
||||
- **Escalation path**: `document-in-feature` for contained audit metadata placement notes; `reject-or-split` for any drift into new persistence, portal scope, or expanded browser coverage
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Why no dedicated follow-up spec is needed**: this is already the bounded follow-up to Spec 249; remaining work stays inside this productization lane unless it tries to become publishing/remediation/portal scope
|
||||
|
||||
## Rollout & Risk Controls
|
||||
|
||||
- Keep the canonical entry surface on the existing workspace page and the canonical secondary surface on the existing released-review detail route.
|
||||
- Keep all proof and packaged artifact flows on the existing tenant review, review-pack, and evidence routes. Do not add a new proof viewer or download endpoint.
|
||||
- Treat missing accountability truth or missing proof availability as explicit partial or unavailable disclosure, never as fabricated customer-safe copy.
|
||||
- Prefer localization-key updates in the existing review language namespace over page-local inline wording.
|
||||
- Keep browser validation bounded to the existing smoke harness before considering any wider UI rollout.
|
||||
|
||||
## Guardrail / Smoke Coverage Close-Out
|
||||
|
||||
- **Close-out date**: 2026-04-30
|
||||
- **Confidence lane**: PASS via the focused customer-workspace, TenantReview detail, ReviewPack, EvidenceSnapshot, audit, capability, and download feature suites listed in [quickstart.md](quickstart.md).
|
||||
- **Browser lane**: PASS via the bounded customer-review workspace smoke test. Tested path: `/admin/reviews/workspace` as a readonly-capable actor, released review row visibility, customer-safe pack/proof availability labels, workspace-to-detail handoff, and customer-safe released-review detail text.
|
||||
- **Audit-gap outcome**: bounded additive action IDs were required for explicit workspace entry and proof-open events (`customer_review_workspace.opened`, `evidence_snapshot.opened`). Existing `tenant_review.opened` and `review_pack.downloaded` paths were reused with `source_surface=customer_review_workspace`.
|
||||
- **Localization / copy outcome**: contained to the existing review localization namespace in English and German; no new vocabulary framework or page-local copy layer was introduced.
|
||||
- **Global-search safety outcome**: no new globally searchable resource or search scope was introduced. Touched review, pack, and evidence resources remain on their existing tenant-scoped resource paths and customer-workspace query context.
|
||||
- **Follow-up decision**: no `follow-up-spec` is required for the implemented scope. Broader portal, publication, remediation, baseline/control overlays, and management-packaging expansion remain outside this feature.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/258-customer-review-productization/
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── customer-review-productization.openapi.yaml
|
||||
└── tasks.md # Created later by /speckit.tasks, not by this plan step
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/Reviews/
|
||||
│ │ │ └── CustomerReviewWorkspace.php
|
||||
│ │ └── Resources/
|
||||
│ │ ├── TenantReviewResource.php
|
||||
│ │ ├── TenantReviewResource/Pages/ViewTenantReview.php
|
||||
│ │ ├── ReviewPackResource.php
|
||||
│ │ ├── ReviewPackResource/Pages/ViewReviewPack.php
|
||||
│ │ ├── EvidenceSnapshotResource.php
|
||||
│ │ └── EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
|
||||
│ ├── Http/Controllers/ReviewPackDownloadController.php
|
||||
│ ├── Models/
|
||||
│ │ ├── TenantReview.php
|
||||
│ │ ├── ReviewPack.php
|
||||
│ │ ├── EvidenceSnapshot.php
|
||||
│ │ └── FindingException.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Audit/WorkspaceAuditLogger.php
|
||||
│ │ └── TenantReviews/TenantReviewRegisterService.php
|
||||
│ ├── Support/
|
||||
│ │ ├── Audit/AuditActionId.php
|
||||
│ │ ├── Auth/Capabilities.php
|
||||
│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
|
||||
├── lang/
|
||||
│ ├── de/localization.php
|
||||
│ └── en/localization.php
|
||||
├── bootstrap/providers.php
|
||||
├── resources/views/filament/pages/reviews/customer-review-workspace.blade.php
|
||||
└── tests/
|
||||
├── Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php
|
||||
├── Feature/ReviewPack/ReviewPackDownloadTest.php
|
||||
└── Feature/Reviews/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. The implementation stays inside the existing `apps/platform` reviews, review-pack, evidence, localization, and audit surfaces, with no new panel/provider locations and no new persistence layer.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None expected at planning time | The intended implementation is a productization pass over existing pages, routes, copy, and audit seams | Adding a portal, new presenter layer, or persisted customer-review projection would import unnecessary structure |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: the repo already has customer-review truth, but the current workspace and drilldown still feel too operator-led and under-explain accountability, proof, and unavailable states for a customer-safe governance record.
|
||||
- **Existing structure is insufficient because**: Spec 249 created the canonical entry route, but the product contract across workspace summary, released-review detail, proof pointers, pack access, and auditable access semantics is still incomplete.
|
||||
- **Narrowest correct implementation**: tighten the existing workspace page, released-review detail, proof affordances, localization copy, and shared audit metadata without adding a new page shell, persistence, or customer-specific presenter family.
|
||||
- **Ownership cost created**: limited copy/disclosure maintenance on existing surfaces, a small extension to focused tests, and at most bounded additive audit action IDs if current coverage is incomplete.
|
||||
- **Alternative intentionally rejected**: a new portal, publication engine, remediation flow, or second customer-review explanation framework was rejected because the repo already has the required read-only truth seams.
|
||||
- **Release truth**: current-release productization follow-up to Spec 249.
|
||||
|
||||
## Phase 0 — Research (output: research.md)
|
||||
|
||||
Research resolves the remaining implementation-shaping decisions:
|
||||
|
||||
- keep the existing `CustomerReviewWorkspace` page as the canonical customer-safe landing surface
|
||||
- keep `ViewTenantReview` as the secondary detail surface under the current `customer_workspace` query flag
|
||||
- reuse existing localization, artifact-truth, and accepted-risk seams instead of adding a second vocabulary
|
||||
- keep workspace and tenant isolation on the current capability-first RBAC paths
|
||||
- reuse the existing audit pipeline and identify only the bounded missing access moments that may need additive action IDs
|
||||
- keep browser coverage bounded to the existing workspace smoke path and focused feature tests
|
||||
|
||||
**Output**: [research.md](research.md)
|
||||
|
||||
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
|
||||
|
||||
Design artifacts capture the narrow productization shape:
|
||||
|
||||
- no new persistence; reused truth stays in tenant reviews, finding exceptions, review packs, evidence snapshots, memberships, and audit logs
|
||||
- one derived workspace presentation contract and one derived released-review disclosure contract document the existing surfaces without becoming stored entities
|
||||
- the conceptual contract documents current workspace, review detail, proof, and pack-download route expectations plus explicit access/absence/unavailable semantics
|
||||
- quickstart records the intended implementation order, bounded validation commands, Filament v5 / Livewire v4 posture, provider-registration location, and no-new-assets expectation
|
||||
|
||||
**Artifacts**:
|
||||
|
||||
- [data-model.md](data-model.md)
|
||||
- [contracts/customer-review-productization.openapi.yaml](contracts/customer-review-productization.openapi.yaml)
|
||||
- [quickstart.md](quickstart.md)
|
||||
|
||||
## Phase 2 — Planning (for tasks.md)
|
||||
|
||||
Dependency-ordered implementation outline for the later `tasks.md` step:
|
||||
|
||||
1. Tighten the existing workspace page and Blade intro so the default-visible path is calm, customer-safe, and explicit about absence/unavailable states.
|
||||
2. Tighten the existing released-review detail flow under the `customer_workspace` context flag so it remains read-only and deepens understanding without exposing operator lifecycle actions.
|
||||
3. Reuse existing review summary, finding outcome, accepted-risk, proof, and pack truth to improve explanation quality and customer-safe disclosure hierarchy without adding a second presenter or new persistence.
|
||||
4. Align proof pointers and review-pack affordances so optional deep links are capability-gated and unavailable states are explicit.
|
||||
5. Reuse the shared audit pipeline for workspace access, review access, proof access, and pack downloads, adding only bounded audit registry entries if the current actions do not cover required moments.
|
||||
6. Expand the focused review feature suite and keep the single existing browser smoke as the only browser proof for this slice.
|
||||
|
||||
## Planning Guardrail Notes
|
||||
|
||||
- Planning guardrail result: PASS. Filament remains v5 on Livewire v4, panel providers remain in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new global-search scope is introduced, no destructive action is added, and no new asset bundle is planned.
|
||||
- Shared seam result: the plan stays on existing page/resource/service/audit seams, not a new customer-review framework.
|
||||
- Smoke plan: the existing [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) remains the single bounded browser proof.
|
||||
- Agent context update: intentionally skipped during this plan pass because the feature introduces no new technology and the user requested preparation-artifact-only changes.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user