feat(support-diagnostics): guardrail refactor and UI polish (agent) (#278)
Some checks failed
Main Confidence / confidence (push) Failing after 45s
Some checks failed
Main Confidence / confidence (push) Failing after 45s
Implements support diagnostics bundle, moves audit writes to action mountUsing to avoid side-effects during render, replaces custom slide-over with Filament-native schema, updates tests and adds spec docs. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #278
This commit is contained in:
parent
ab6eccaf40
commit
17d3ca8313
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -258,6 +258,8 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness)
|
||||
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
|
||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
|
||||
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -292,9 +294,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
@ -25,6 +26,8 @@
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
@ -39,6 +42,7 @@
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
@ -81,6 +85,11 @@ public function getTitle(): string|Htmlable
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
@ -130,6 +139,10 @@ protected function getHeaderActions(): array
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: OperationRunLinks::index());
|
||||
|
||||
if (isset($this->run)) {
|
||||
$actions[] = $this->openSupportDiagnosticsAction();
|
||||
}
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
}
|
||||
@ -208,6 +221,116 @@ public function monitoringDetailSummary(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function openSupportDiagnosticsAction(): Action
|
||||
{
|
||||
$action = Action::make('openSupportDiagnostics')
|
||||
->label('Open support diagnostics')
|
||||
->icon('heroicon-o-lifebuoy')
|
||||
->iconButton()
|
||||
->tooltip('Open support diagnostics')
|
||||
->color('gray')
|
||||
->record($this->run)
|
||||
->modal()
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading('Support diagnostics')
|
||||
->modalDescription('Redacted operation context from existing records.')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||
->mountUsing(function (): void {
|
||||
$this->auditOperationSupportDiagnosticsOpen();
|
||||
})
|
||||
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
|
||||
'bundle' => $this->operationRunSupportDiagnosticBundle(),
|
||||
]));
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function operationRunSupportDiagnosticBundle(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = $this->supportDiagnosticsTenant();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
||||
}
|
||||
|
||||
private function auditOperationSupportDiagnosticsOpen(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = $this->supportDiagnosticsTenant();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->recordSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
bundle: $this->operationRunSupportDiagnosticBundle(),
|
||||
user: $user,
|
||||
);
|
||||
}
|
||||
|
||||
private function supportDiagnosticsTenant(): ?Tenant
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return $this->run->loadMissing('tenant')->tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$auditKey = 'operation:'.$this->run->getKey();
|
||||
|
||||
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
contextType: 'operation_run',
|
||||
bundle: $bundle,
|
||||
actor: $user,
|
||||
operationRun: $this->run,
|
||||
);
|
||||
|
||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -11,13 +11,28 @@
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
@ -46,4 +61,101 @@ public function getColumns(): int|array
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->openSupportDiagnosticsAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function openSupportDiagnosticsAction(): Action
|
||||
{
|
||||
$action = Action::make('openSupportDiagnostics')
|
||||
->label('Open support diagnostics')
|
||||
->icon('heroicon-o-lifebuoy')
|
||||
->color('gray')
|
||||
->modal()
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading('Support diagnostics')
|
||||
->modalDescription('Redacted tenant context from existing records.')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||
->mountUsing(function (): void {
|
||||
$this->auditTenantSupportDiagnosticsOpen();
|
||||
})
|
||||
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
|
||||
'bundle' => $this->tenantSupportDiagnosticBundle(),
|
||||
]));
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function tenantSupportDiagnosticBundle(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
||||
}
|
||||
|
||||
private function auditTenantSupportDiagnosticsOpen(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->recordSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
bundle: $this->tenantSupportDiagnosticBundle(),
|
||||
user: $user,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
|
||||
{
|
||||
$auditKey = 'tenant:'.$tenant->getKey();
|
||||
|
||||
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
|
||||
tenant: $tenant,
|
||||
contextType: 'tenant',
|
||||
bundle: $bundle,
|
||||
actor: $user,
|
||||
);
|
||||
|
||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
@ -87,4 +88,49 @@ public function logTenantLifecycleAction(
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
public function logSupportDiagnosticsOpened(
|
||||
Tenant $tenant,
|
||||
string $contextType,
|
||||
array $bundle,
|
||||
?User $actor = null,
|
||||
?OperationRun $operationRun = null,
|
||||
): \App\Models\AuditLog {
|
||||
$sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0;
|
||||
$referenceCount = collect($bundle['sections'] ?? [])
|
||||
->sum(static fn (mixed $section): int => is_array($section) && is_array($section['references'] ?? null)
|
||||
? count($section['references'])
|
||||
: 0);
|
||||
|
||||
return $this->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::SupportDiagnosticsOpened,
|
||||
context: [
|
||||
'context_type' => $contextType,
|
||||
'redaction_mode' => 'default_redacted',
|
||||
'section_count' => $sectionCount,
|
||||
'reference_count' => $referenceCount,
|
||||
'primary_context_id' => $operationRun instanceof OperationRun
|
||||
? (string) $operationRun->getKey()
|
||||
: (string) $tenant->getKey(),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'success',
|
||||
resourceType: 'support_diagnostic_bundle',
|
||||
resourceId: $operationRun instanceof OperationRun
|
||||
? 'operation_run:'.$operationRun->getKey()
|
||||
: 'tenant:'.$tenant->getKey(),
|
||||
targetLabel: $operationRun instanceof OperationRun
|
||||
? 'Support diagnostics for operation #'.$operationRun->getKey()
|
||||
: 'Support diagnostics for '.$tenant->name,
|
||||
summary: $operationRun instanceof OperationRun
|
||||
? 'Support diagnostics opened for operation #'.$operationRun->getKey()
|
||||
: 'Support diagnostics opened for '.$tenant->name,
|
||||
operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_DELETE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -63,6 +64,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -103,6 +105,7 @@ class RoleCapabilityMap
|
||||
TenantRole::Operator->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
|
||||
@ -99,6 +99,8 @@ enum AuditActionId: string
|
||||
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||
|
||||
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
||||
|
||||
// Workspace selection / switch events (Spec 107).
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
case WorkspaceSelected = 'workspace.selected';
|
||||
@ -234,6 +236,7 @@ private static function labels(): array
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||
'baseline.capture.started' => 'Baseline capture started',
|
||||
'baseline.capture.completed' => 'Baseline capture completed',
|
||||
'baseline.capture.failed' => 'Baseline capture failed',
|
||||
@ -315,6 +318,7 @@ private static function summaries(): array
|
||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -69,6 +69,9 @@ class Capabilities
|
||||
|
||||
public const TENANT_SYNC = 'tenant.sync';
|
||||
|
||||
// Support diagnostics
|
||||
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
|
||||
|
||||
// Inventory
|
||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||
|
||||
|
||||
@ -15,6 +15,35 @@ public static function protectedValueNote(): string
|
||||
return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.';
|
||||
}
|
||||
|
||||
public static function supportDiagnosticsNote(): string
|
||||
{
|
||||
return 'Support diagnostics are default-redacted. Secrets, credentials, raw provider payloads, full response bodies, and unrestricted log excerpts are intentionally excluded.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{path: ?string, reason: string, replacement_text: string}>
|
||||
*/
|
||||
public static function supportDiagnosticsMarkers(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'path' => 'provider_connection.credential',
|
||||
'reason' => 'credential',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
[
|
||||
'path' => 'stored_reports.payload',
|
||||
'reason' => 'raw_payload',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
[
|
||||
'path' => 'audit_logs.metadata.raw',
|
||||
'reason' => 'restricted_log_excerpt',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function noteForPolicyVersion(PolicyVersion $version): ?string
|
||||
{
|
||||
if (self::fingerprintCount($version->secret_fingerprints) > 0) {
|
||||
|
||||
@ -0,0 +1,942 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\SupportDiagnostics;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class SupportDiagnosticBundleBuilder
|
||||
{
|
||||
private const SECTION_ORDER = [
|
||||
'overview',
|
||||
'provider_connection',
|
||||
'operation_context',
|
||||
'findings',
|
||||
'stored_reports',
|
||||
'tenant_review',
|
||||
'review_pack',
|
||||
'audit_history',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder,
|
||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||
private readonly RelatedNavigationResolver $relatedNavigationResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forTenant(Tenant $tenant, ?User $actor = null): array
|
||||
{
|
||||
$tenant->loadMissing('workspace');
|
||||
|
||||
$workspace = $tenant->workspace;
|
||||
$providerConnection = $this->tenantProviderConnection($tenant);
|
||||
$operationRun = $this->tenantOperationRun($tenant);
|
||||
$findings = $this->tenantFindings($tenant);
|
||||
$storedReports = $this->tenantStoredReports($tenant);
|
||||
$tenantReview = $this->tenantReview($tenant);
|
||||
$reviewPack = $this->tenantReviewPack($tenant, $tenantReview);
|
||||
$auditLogs = $this->tenantAuditLogs($tenant);
|
||||
|
||||
return $this->bundle(
|
||||
contextType: 'tenant',
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
operationRun: $operationRun,
|
||||
headline: 'Support diagnostics for '.$tenant->name,
|
||||
dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings),
|
||||
sections: [
|
||||
$this->overviewSection($workspace, $tenant, $operationRun),
|
||||
$this->providerConnectionSection($providerConnection, $tenant),
|
||||
$this->operationContextSection($operationRun, $tenant),
|
||||
$this->findingsSection($findings, $tenant),
|
||||
$this->storedReportsSection($storedReports),
|
||||
$this->tenantReviewSection($tenantReview, $tenant),
|
||||
$this->reviewPackSection($reviewPack, $tenant),
|
||||
$this->auditHistorySection($auditLogs),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forOperationRun(OperationRun $run, ?User $actor = null): array
|
||||
{
|
||||
$run->loadMissing(['workspace', 'tenant']);
|
||||
|
||||
$workspace = $run->workspace;
|
||||
$tenant = $run->tenant;
|
||||
$providerConnection = $tenant instanceof Tenant
|
||||
? $this->operationProviderConnection($run, $tenant)
|
||||
: null;
|
||||
$findings = $tenant instanceof Tenant ? $this->operationFindings($run, $tenant) : collect();
|
||||
$storedReports = $tenant instanceof Tenant ? $this->tenantStoredReports($tenant) : collect();
|
||||
$tenantReview = $tenant instanceof Tenant ? $this->operationTenantReview($run, $tenant) : null;
|
||||
$reviewPack = $tenant instanceof Tenant ? $this->operationReviewPack($run, $tenant, $tenantReview) : null;
|
||||
$auditLogs = $this->operationAuditLogs($run);
|
||||
$runSummary = $this->runSummaryBuilder->build($run);
|
||||
$runSummaryArray = $runSummary?->toArray();
|
||||
|
||||
return $this->bundle(
|
||||
contextType: 'operation_run',
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
operationRun: $run,
|
||||
headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics',
|
||||
dominantIssue: (string) data_get(
|
||||
$runSummaryArray,
|
||||
'dominantCause.explanation',
|
||||
$this->operationDominantIssue($run),
|
||||
),
|
||||
sections: [
|
||||
$this->overviewSection($workspace, $tenant, $run),
|
||||
$this->providerConnectionSection($providerConnection, $tenant, $run),
|
||||
$this->operationContextSection($run, $tenant, $runSummaryArray),
|
||||
$this->findingsSection($findings, $tenant),
|
||||
$this->storedReportsSection($storedReports),
|
||||
$this->tenantReviewSection($tenantReview, $tenant),
|
||||
$this->reviewPackSection($reviewPack, $tenant),
|
||||
$this->auditHistorySection($auditLogs),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function bundle(
|
||||
string $contextType,
|
||||
?Workspace $workspace,
|
||||
?Tenant $tenant,
|
||||
?OperationRun $operationRun,
|
||||
string $headline,
|
||||
string $dominantIssue,
|
||||
array $sections,
|
||||
): array {
|
||||
$sections = $this->sortSections($sections);
|
||||
$redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers();
|
||||
|
||||
return [
|
||||
'context_type' => $contextType,
|
||||
'context' => [
|
||||
'type' => $contextType,
|
||||
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
|
||||
'tenant_id' => $tenant instanceof Tenant ? (int) $tenant->getKey() : null,
|
||||
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||
'tenant_label' => $tenant?->name,
|
||||
'workspace_label' => $workspace?->name,
|
||||
],
|
||||
'workspace' => $workspace instanceof Workspace ? [
|
||||
'record_id' => (string) $workspace->getKey(),
|
||||
'label' => $workspace->name,
|
||||
] : null,
|
||||
'tenant' => $tenant instanceof Tenant ? $this->tenantReference($tenant) : null,
|
||||
'operation_run' => $operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null,
|
||||
'headline' => $headline,
|
||||
'dominant_issue' => $dominantIssue,
|
||||
'freshness_state' => $this->freshnessState($sections),
|
||||
'redaction_mode' => 'default_redacted',
|
||||
'summary' => [
|
||||
'headline' => $headline,
|
||||
'dominant_issue' => $dominantIssue,
|
||||
'freshness_state' => $this->freshnessState($sections),
|
||||
'completeness_note' => $this->completenessNote($sections),
|
||||
'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(),
|
||||
'generated_from' => 'derived_existing_truth',
|
||||
],
|
||||
'sections' => $sections,
|
||||
'redaction' => [
|
||||
'mode' => 'default_redacted',
|
||||
'markers' => $redactionMarkers,
|
||||
],
|
||||
'notes' => array_values(array_filter([
|
||||
RedactionIntegrity::supportDiagnosticsNote(),
|
||||
$this->completenessNote($sections),
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection
|
||||
{
|
||||
return ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('last_health_check_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function operationProviderConnection(OperationRun $run, Tenant $tenant): ?ProviderConnection
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$providerConnectionId = data_get($context, 'provider_connection_id');
|
||||
|
||||
if (is_numeric($providerConnectionId)) {
|
||||
$connection = ProviderConnection::query()
|
||||
->whereKey((int) $providerConnectionId)
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->first();
|
||||
|
||||
if ($connection instanceof ProviderConnection) {
|
||||
return $connection;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->tenantProviderConnection($tenant);
|
||||
}
|
||||
|
||||
private function tenantOperationRun(Tenant $tenant): ?OperationRun
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByRaw('completed_at IS NULL')
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
private function tenantFindings(Tenant $tenant): Collection
|
||||
{
|
||||
return Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('last_seen_at')
|
||||
->orderBy('id')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
private function operationFindings(OperationRun $run, Tenant $tenant): Collection
|
||||
{
|
||||
$runBound = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where(function (Builder $query) use ($run): void {
|
||||
$query
|
||||
->where('current_operation_run_id', (int) $run->getKey())
|
||||
->orWhere('baseline_operation_run_id', (int) $run->getKey());
|
||||
})
|
||||
->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('last_seen_at')
|
||||
->orderBy('id')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
return $runBound->isNotEmpty() ? $runBound : $this->tenantFindings($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StoredReport>
|
||||
*/
|
||||
private function tenantStoredReports(Tenant $tenant): Collection
|
||||
{
|
||||
return StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function tenantReview(Tenant $tenant): ?TenantReview
|
||||
{
|
||||
return TenantReview::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function operationTenantReview(OperationRun $run, Tenant $tenant): ?TenantReview
|
||||
{
|
||||
$review = TenantReview::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $review instanceof TenantReview ? $review : $this->tenantReview($tenant);
|
||||
}
|
||||
|
||||
private function tenantReviewPack(Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack
|
||||
{
|
||||
if ($tenantReview instanceof TenantReview && is_numeric($tenantReview->current_export_review_pack_id)) {
|
||||
$pack = ReviewPack::query()
|
||||
->whereKey((int) $tenantReview->current_export_review_pack_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->first();
|
||||
|
||||
if ($pack instanceof ReviewPack) {
|
||||
return $pack;
|
||||
}
|
||||
}
|
||||
|
||||
return ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function operationReviewPack(OperationRun $run, Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack
|
||||
{
|
||||
$pack = ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $pack instanceof ReviewPack ? $pack : $this->tenantReviewPack($tenant, $tenantReview);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AuditLog>
|
||||
*/
|
||||
private function tenantAuditLogs(Tenant $tenant): Collection
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->latestFirst()
|
||||
->limit(5)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AuditLog>
|
||||
*/
|
||||
private function operationAuditLogs(OperationRun $run): Collection
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where(function (Builder $query) use ($run): void {
|
||||
$query
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->orWhere(function (Builder $targetQuery) use ($run): void {
|
||||
$targetQuery
|
||||
->where('resource_type', 'operation_run')
|
||||
->where('resource_id', (string) $run->getKey());
|
||||
});
|
||||
})
|
||||
->latestFirst()
|
||||
->limit(5)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function tenantDominantIssue(?ProviderConnection $providerConnection, ?OperationRun $operationRun, Collection $findings): string
|
||||
{
|
||||
if ($providerConnection instanceof ProviderConnection) {
|
||||
$providerIssue = $this->providerIssue($providerConnection);
|
||||
|
||||
if ($providerIssue !== null) {
|
||||
return $providerIssue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($operationRun instanceof OperationRun && in_array((string) $operationRun->outcome, ['failed', 'blocked', 'partially_succeeded'], true)) {
|
||||
return $this->operationDominantIssue($operationRun);
|
||||
}
|
||||
|
||||
if ($findings->isNotEmpty()) {
|
||||
return 'Open findings need review before support can treat this tenant as quiet.';
|
||||
}
|
||||
|
||||
return 'No dominant support blocker is currently visible from the selected tenant context.';
|
||||
}
|
||||
|
||||
private function operationDominantIssue(OperationRun $run): string
|
||||
{
|
||||
$failure = collect(is_array($run->failure_summary) ? $run->failure_summary : [])
|
||||
->first(static fn (mixed $item): bool => is_array($item) && trim((string) ($item['message'] ?? '')) !== '');
|
||||
|
||||
if (is_array($failure)) {
|
||||
return trim((string) $failure['message']);
|
||||
}
|
||||
|
||||
return match ((string) $run->outcome) {
|
||||
'failed' => 'The operation failed and needs follow-up.',
|
||||
'blocked' => 'The operation was blocked by a prerequisite or policy condition.',
|
||||
'partially_succeeded' => 'The operation completed with degraded or partial results.',
|
||||
default => 'The operation is available for support review.',
|
||||
};
|
||||
}
|
||||
|
||||
private function providerIssue(ProviderConnection $connection): ?string
|
||||
{
|
||||
$reasonCode = trim((string) $connection->last_error_reason_code);
|
||||
|
||||
if ($reasonCode !== '') {
|
||||
$envelope = $this->providerReasonTranslator->translate($reasonCode, 'support_diagnostics', [
|
||||
'tenant' => $connection->tenant,
|
||||
'connection' => $connection,
|
||||
]);
|
||||
|
||||
if ($envelope !== null) {
|
||||
return $envelope->operatorLabel.': '.$envelope->shortExplanation;
|
||||
}
|
||||
}
|
||||
|
||||
$surface = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
|
||||
if ($surface->readinessSummary !== 'Ready') {
|
||||
return 'Provider connection '.$surface->readinessSummary.'.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function overviewSection(?Workspace $workspace, ?Tenant $tenant, ?OperationRun $operationRun): array
|
||||
{
|
||||
$references = array_values(array_filter([
|
||||
$tenant instanceof Tenant ? $this->tenantReference($tenant) : null,
|
||||
$operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null,
|
||||
]));
|
||||
|
||||
return $this->section(
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
availability: $references === [] ? 'missing' : 'available',
|
||||
summary: $tenant instanceof Tenant
|
||||
? 'Workspace and tenant scope resolved before support diagnostics were composed.'
|
||||
: 'Workspace scope resolved; no tenant context is attached to this operation.',
|
||||
references: $references,
|
||||
);
|
||||
}
|
||||
|
||||
private function providerConnectionSection(?ProviderConnection $connection, ?Tenant $tenant, ?OperationRun $run = null): array
|
||||
{
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return $this->section(
|
||||
key: 'provider_connection',
|
||||
label: 'Provider connection',
|
||||
availability: 'missing',
|
||||
summary: 'No provider connection was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('provider_connection', 'Provider connection not observed', 'Open provider connection'),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'provider_connection.credential',
|
||||
'reason' => 'credential',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$surface = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
$providerIssue = $this->providerIssue($connection);
|
||||
|
||||
return $this->section(
|
||||
key: 'provider_connection',
|
||||
label: 'Provider connection',
|
||||
availability: $surface->readinessSummary === 'Ready' ? 'available' : 'stale',
|
||||
summary: $providerIssue ?? sprintf(
|
||||
'%s provider connection is %s.',
|
||||
ucfirst($surface->provider),
|
||||
strtolower($surface->readinessSummary),
|
||||
),
|
||||
freshnessNote: $this->freshnessNote($connection->last_health_check_at, 'Last health check'),
|
||||
references: [
|
||||
$this->modelReference(
|
||||
type: 'provider_connection',
|
||||
record: $connection,
|
||||
label: $connection->display_name ?: 'Provider connection #'.$connection->getKey(),
|
||||
actionLabel: 'Open provider connection',
|
||||
url: ProviderConnectionResource::getUrl('view', ['record' => $connection], panel: 'admin'),
|
||||
freshnessAt: $connection->last_health_check_at,
|
||||
),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'provider_connection.credential',
|
||||
'reason' => 'credential',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function operationContextSection(?OperationRun $operationRun, ?Tenant $tenant, ?array $runSummary = null): array
|
||||
{
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return $this->section(
|
||||
key: 'operation_context',
|
||||
label: 'Operation context',
|
||||
availability: 'missing',
|
||||
summary: 'No recent operation context was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('operation_run', 'Operation not yet observed', OperationRunLinks::openLabel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$runSummary ??= $this->runSummaryBuilder->build($operationRun)?->toArray();
|
||||
|
||||
return $this->section(
|
||||
key: 'operation_context',
|
||||
label: 'Operation context',
|
||||
availability: 'available',
|
||||
summary: (string) ($runSummary['headline'] ?? $this->operationDominantIssue($operationRun)),
|
||||
freshnessNote: $this->freshnessNote($operationRun->completed_at ?? $operationRun->updated_at, 'Last run update'),
|
||||
references: [
|
||||
$this->operationReference($operationRun, $tenant),
|
||||
...$this->operationRelatedReferences($operationRun, $tenant),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function findingsSection(Collection $findings, ?Tenant $tenant): array
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return $this->section(
|
||||
key: 'findings',
|
||||
label: 'Findings',
|
||||
availability: 'missing',
|
||||
summary: 'No open or run-related findings were found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('finding', 'No relevant finding observed', 'Open finding'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'findings',
|
||||
label: 'Findings',
|
||||
availability: 'available',
|
||||
summary: sprintf('%d finding reference(s) are included for support triage.', $findings->count()),
|
||||
freshnessNote: $this->freshnessNote($findings->max('last_seen_at'), 'Latest finding'),
|
||||
references: $findings
|
||||
->map(fn (Finding $finding): array => $this->modelReference(
|
||||
type: 'finding',
|
||||
record: $finding,
|
||||
label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()),
|
||||
actionLabel: 'Open finding',
|
||||
url: $tenant instanceof Tenant
|
||||
? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)
|
||||
: null,
|
||||
freshnessAt: $finding->last_seen_at,
|
||||
))
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
|
||||
private function storedReportsSection(Collection $reports): array
|
||||
{
|
||||
if ($reports->isEmpty()) {
|
||||
return $this->section(
|
||||
key: 'stored_reports',
|
||||
label: 'Stored reports',
|
||||
availability: 'missing',
|
||||
summary: 'No stored report identity was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('stored_report', 'Stored report not yet observed', 'Review stored report identity'),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'stored_reports.payload',
|
||||
'reason' => 'raw_payload',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'stored_reports',
|
||||
label: 'Stored reports',
|
||||
availability: 'redacted',
|
||||
summary: sprintf('%d stored report identity reference(s) are included without raw report payloads.', $reports->count()),
|
||||
freshnessNote: $this->freshnessNote($reports->max('updated_at'), 'Latest stored report'),
|
||||
references: $reports
|
||||
->map(fn (StoredReport $report): array => $this->modelReference(
|
||||
type: 'stored_report',
|
||||
record: $report,
|
||||
label: str_replace('_', ' ', (string) $report->report_type).' report #'.$report->getKey(),
|
||||
actionLabel: 'Review stored report identity',
|
||||
url: null,
|
||||
freshnessAt: $report->updated_at,
|
||||
))
|
||||
->values()
|
||||
->all(),
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'stored_reports.payload',
|
||||
'reason' => 'raw_payload',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function tenantReviewSection(?TenantReview $review, ?Tenant $tenant): array
|
||||
{
|
||||
if (! $review instanceof TenantReview) {
|
||||
return $this->section(
|
||||
key: 'tenant_review',
|
||||
label: 'Tenant review',
|
||||
availability: 'missing',
|
||||
summary: 'No tenant review was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('tenant_review', 'Tenant review not yet observed', 'Open tenant review'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'tenant_review',
|
||||
label: 'Tenant review',
|
||||
availability: 'available',
|
||||
summary: sprintf('Latest tenant review is %s with %s completeness.', (string) $review->status, (string) $review->completeness_state),
|
||||
freshnessNote: $this->freshnessNote($review->generated_at, 'Generated'),
|
||||
references: [
|
||||
$this->modelReference(
|
||||
type: 'tenant_review',
|
||||
record: $review,
|
||||
label: 'Tenant review #'.$review->getKey(),
|
||||
actionLabel: 'Open tenant review',
|
||||
url: $tenant instanceof Tenant
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)
|
||||
: null,
|
||||
freshnessAt: $review->generated_at,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function reviewPackSection(?ReviewPack $pack, ?Tenant $tenant): array
|
||||
{
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return $this->section(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
availability: 'missing',
|
||||
summary: 'No review pack was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('review_pack', 'Review pack not yet observed', 'Open review pack'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
availability: $pack->isExpired() ? 'stale' : 'available',
|
||||
summary: sprintf('Review pack #%d is %s.', (int) $pack->getKey(), (string) $pack->status),
|
||||
freshnessNote: $this->freshnessNote($pack->generated_at, 'Generated'),
|
||||
references: [
|
||||
$this->modelReference(
|
||||
type: 'review_pack',
|
||||
record: $pack,
|
||||
label: 'Review pack #'.$pack->getKey(),
|
||||
actionLabel: 'Open review pack',
|
||||
url: $tenant instanceof Tenant
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant)
|
||||
: null,
|
||||
freshnessAt: $pack->generated_at,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function auditHistorySection(Collection $auditLogs): array
|
||||
{
|
||||
if ($auditLogs->isEmpty()) {
|
||||
return $this->section(
|
||||
key: 'audit_history',
|
||||
label: 'Audit history',
|
||||
availability: 'missing',
|
||||
summary: 'No audit references were found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('audit_log', 'Audit event not yet observed', 'Inspect event'),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'audit_logs.metadata.raw',
|
||||
'reason' => 'restricted_log_excerpt',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'audit_history',
|
||||
label: 'Audit history',
|
||||
availability: 'redacted',
|
||||
summary: sprintf('%d audit reference(s) are included with redacted metadata only.', $auditLogs->count()),
|
||||
freshnessNote: $this->freshnessNote($auditLogs->max('recorded_at'), 'Latest audit event'),
|
||||
references: $auditLogs
|
||||
->map(fn (AuditLog $auditLog): array => $this->modelReference(
|
||||
type: 'audit_log',
|
||||
record: $auditLog,
|
||||
label: $auditLog->summaryText(),
|
||||
actionLabel: 'Inspect event',
|
||||
url: route('admin.monitoring.audit-log', ['event' => (int) $auditLog->getKey()]),
|
||||
freshnessAt: $auditLog->recorded_at,
|
||||
))
|
||||
->values()
|
||||
->all(),
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'audit_logs.metadata.raw',
|
||||
'reason' => 'restricted_log_excerpt',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $references
|
||||
* @param list<array{path: ?string, reason: string, replacement_text: string}> $redactionMarkers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function section(
|
||||
string $key,
|
||||
string $label,
|
||||
string $availability,
|
||||
string $summary,
|
||||
?string $freshnessNote = null,
|
||||
array $references = [],
|
||||
array $redactionMarkers = [],
|
||||
): array {
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'availability' => $availability,
|
||||
'summary' => $summary,
|
||||
'freshness_note' => $freshnessNote,
|
||||
'references' => $this->sortReferences($references),
|
||||
'redaction_markers' => $redactionMarkers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortSections(array $sections): array
|
||||
{
|
||||
$order = array_flip(self::SECTION_ORDER);
|
||||
|
||||
usort($sections, static fn (array $left, array $right): int => ($order[$left['key']] ?? 999) <=> ($order[$right['key']] ?? 999));
|
||||
|
||||
return array_values($sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $references
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortReferences(array $references): array
|
||||
{
|
||||
usort($references, function (array $left, array $right): int {
|
||||
$leftAvailability = $left['availability'] === 'available' ? 0 : 1;
|
||||
$rightAvailability = $right['availability'] === 'available' ? 0 : 1;
|
||||
|
||||
if ($leftAvailability !== $rightAvailability) {
|
||||
return $leftAvailability <=> $rightAvailability;
|
||||
}
|
||||
|
||||
return [
|
||||
(string) ($left['type'] ?? ''),
|
||||
(string) ($left['record_id'] ?? ''),
|
||||
(string) ($left['label'] ?? ''),
|
||||
] <=> [
|
||||
(string) ($right['type'] ?? ''),
|
||||
(string) ($right['record_id'] ?? ''),
|
||||
(string) ($right['label'] ?? ''),
|
||||
];
|
||||
});
|
||||
|
||||
return array_values($references);
|
||||
}
|
||||
|
||||
private function tenantReference(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'type' => 'tenant',
|
||||
'record_id' => (string) $tenant->getKey(),
|
||||
'label' => $tenant->name,
|
||||
'action_label' => 'Open tenant',
|
||||
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
|
||||
'availability' => 'available',
|
||||
'freshness_note' => null,
|
||||
'access_reason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function operationReference(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
return $this->modelReference(
|
||||
type: 'operation_run',
|
||||
record: $run,
|
||||
label: OperationRunLinks::identifier($run),
|
||||
actionLabel: OperationRunLinks::openLabel(),
|
||||
url: OperationRunLinks::tenantlessView($run),
|
||||
freshnessAt: $run->completed_at ?? $run->updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function operationRelatedReferences(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($this->relatedNavigationResolver->operationLinks($run, $tenant))
|
||||
->reject(static fn (string $url, string $label): bool => $label === OperationRunLinks::collectionLabel())
|
||||
->map(fn (string $url, string $label): array => [
|
||||
'type' => 'operation_run',
|
||||
'record_id' => (string) $run->getKey(),
|
||||
'label' => $label,
|
||||
'action_label' => $label,
|
||||
'url' => $url,
|
||||
'availability' => 'available',
|
||||
'freshness_note' => null,
|
||||
'access_reason' => null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function modelReference(
|
||||
string $type,
|
||||
Model $record,
|
||||
string $label,
|
||||
string $actionLabel,
|
||||
?string $url,
|
||||
mixed $freshnessAt = null,
|
||||
): array {
|
||||
return [
|
||||
'type' => $type,
|
||||
'record_id' => (string) $record->getKey(),
|
||||
'label' => $label,
|
||||
'action_label' => $actionLabel,
|
||||
'url' => $url,
|
||||
'availability' => $url === null && $type !== 'stored_report' ? 'inaccessible' : 'available',
|
||||
'freshness_note' => $this->freshnessNote($freshnessAt),
|
||||
'access_reason' => $url === null && $type !== 'stored_report' ? 'Canonical destination is not available from this context.' : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function missingReference(string $type, string $label, string $actionLabel): array
|
||||
{
|
||||
return [
|
||||
'type' => $type,
|
||||
'record_id' => null,
|
||||
'label' => $label,
|
||||
'action_label' => $actionLabel,
|
||||
'url' => null,
|
||||
'availability' => 'missing',
|
||||
'freshness_note' => null,
|
||||
'access_reason' => 'No authorized record is available for this support context.',
|
||||
];
|
||||
}
|
||||
|
||||
private function freshnessNote(mixed $value, string $prefix = 'Observed'): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $prefix.': '.$value->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $prefix.': '.trim($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
*/
|
||||
private function freshnessState(array $sections): string
|
||||
{
|
||||
$availableCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'available'));
|
||||
$missingCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'missing'));
|
||||
$staleCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'stale'));
|
||||
$redactedCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'redacted'));
|
||||
|
||||
if ($availableCount === 0 && $redactedCount === 0) {
|
||||
return 'missing_context';
|
||||
}
|
||||
|
||||
if ($missingCount > 0 || $staleCount > 0) {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
*/
|
||||
private function completenessNote(array $sections): ?string
|
||||
{
|
||||
$missing = collect($sections)
|
||||
->filter(static fn (array $section): bool => $section['availability'] === 'missing')
|
||||
->pluck('label')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($missing === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Missing context: '.implode(', ', $missing).'.';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
@php
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @var array<string, mixed> $bundle */
|
||||
$summary = is_array($bundle['summary'] ?? null) ? $bundle['summary'] : [];
|
||||
$context = is_array($bundle['context'] ?? null) ? $bundle['context'] : [];
|
||||
$sections = is_array($bundle['sections'] ?? null) ? $bundle['sections'] : [];
|
||||
$redaction = is_array($bundle['redaction'] ?? null) ? $bundle['redaction'] : [];
|
||||
$notes = is_array($bundle['notes'] ?? null) ? $bundle['notes'] : [];
|
||||
|
||||
$availabilityColor = static function (?string $availability): string {
|
||||
return match ($availability) {
|
||||
'available', 'current', 'fresh', 'ready' => 'success',
|
||||
'partial', 'stale' => 'warning',
|
||||
'error', 'missing', 'unavailable' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
};
|
||||
|
||||
$referenceDescription = static function (array $reference): string {
|
||||
$parts = [
|
||||
is_string($reference['type'] ?? null) && trim((string) $reference['type']) !== ''
|
||||
? (string) $reference['type']
|
||||
: 'reference',
|
||||
is_string($reference['availability'] ?? null) && trim((string) $reference['availability']) !== ''
|
||||
? (string) $reference['availability']
|
||||
: 'missing',
|
||||
];
|
||||
|
||||
if (is_string($reference['freshness_note'] ?? null) && trim((string) $reference['freshness_note']) !== '') {
|
||||
$parts[] = (string) $reference['freshness_note'];
|
||||
}
|
||||
|
||||
return implode(' - ', $parts);
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
:heading="data_get($summary, 'headline', data_get($bundle, 'headline', 'Support diagnostics'))"
|
||||
:description="data_get($summary, 'dominant_issue', data_get($bundle, 'dominant_issue', 'No dominant issue available.'))"
|
||||
>
|
||||
<x-slot name="afterHeader">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ data_get($context, 'type', data_get($bundle, 'context_type', 'tenant')) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ data_get($summary, 'freshness_state', data_get($bundle, 'freshness_state', 'mixed')) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
{{ str_replace('_', '-', (string) data_get($redaction, 'mode', data_get($bundle, 'redaction_mode', 'default-redacted'))) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Workspace</dt>
|
||||
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'workspace_label', 'Workspace unavailable') }}</dd>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant</dt>
|
||||
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'tenant_label', 'Tenant unavailable') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($notes !== [])
|
||||
<x-filament::section
|
||||
heading="Support notes"
|
||||
description="The bundle stays read-only and redacted even when the source records include provider-only details."
|
||||
compact
|
||||
>
|
||||
<div class="space-y-2">
|
||||
@foreach ($notes as $note)
|
||||
@if (is_string($note) && trim($note) !== '')
|
||||
<div class="flex items-start gap-2">
|
||||
<x-filament::badge color="warning" size="sm">Note</x-filament::badge>
|
||||
<p class="text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $note }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach ($sections as $section)
|
||||
@php
|
||||
$references = is_array($section['references'] ?? null) ? $section['references'] : [];
|
||||
$markers = is_array($section['redaction_markers'] ?? null) ? $section['redaction_markers'] : [];
|
||||
$availability = is_string($section['availability'] ?? null) && trim((string) $section['availability']) !== ''
|
||||
? (string) $section['availability']
|
||||
: 'missing';
|
||||
$sectionLabel = $section['label'] ?? $section['key'] ?? 'Section';
|
||||
$sectionSummary = $section['summary'] ?? 'No summary available.';
|
||||
@endphp
|
||||
|
||||
<x-filament::section
|
||||
:heading="$sectionLabel"
|
||||
:description="$sectionSummary"
|
||||
compact
|
||||
>
|
||||
<x-slot name="afterHeader">
|
||||
<x-filament::badge :color="$availabilityColor($availability)" size="sm">
|
||||
{{ $availability }}
|
||||
</x-filament::badge>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-3">
|
||||
@if (is_string($section['freshness_note'] ?? null) && trim((string) $section['freshness_note']) !== '')
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $section['freshness_note'] }}</p>
|
||||
@endif
|
||||
|
||||
@if ($references !== [])
|
||||
<div class="space-y-2">
|
||||
@foreach ($references as $reference)
|
||||
@php
|
||||
$referenceLabel = $reference['label'] ?? 'Reference unavailable';
|
||||
$referenceUrl = is_string($reference['url'] ?? null) && trim((string) $reference['url']) !== ''
|
||||
? (string) $reference['url']
|
||||
: null;
|
||||
$referenceActionLabel = $reference['action_label'] ?? ($referenceUrl ? 'Open' : 'Unavailable');
|
||||
@endphp
|
||||
|
||||
<x-filament::section
|
||||
:heading="$referenceLabel"
|
||||
:description="$referenceDescription($reference)"
|
||||
compact
|
||||
secondary
|
||||
>
|
||||
<x-slot name="afterHeader">
|
||||
@if ($referenceUrl)
|
||||
<x-filament::link :href="$referenceUrl" size="sm">
|
||||
{{ $referenceActionLabel }}
|
||||
</x-filament::link>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $referenceActionLabel }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</x-slot>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($markers !== [])
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($markers as $marker)
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
{{ trim((string) (($marker['replacement_text'] ?? '[REDACTED]').' '.Str::of((string) ($marker['reason'] ?? 'redacted'))->replace('_', ' '))) }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function operationSupportDiagnosticsComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||
|
||||
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||
}
|
||||
|
||||
it('opens a redacted support diagnostic bundle from the tenantless operation viewer', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||
|
||||
$connection = ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Contoso Microsoft connection',
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'raw-provider-secret-message',
|
||||
'last_health_check_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()
|
||||
->forTenant($tenant)
|
||||
->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
],
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'raw_response_body' => 'secret-provider-body',
|
||||
],
|
||||
'failure_summary' => [[
|
||||
'message' => 'Run failed after provider validation.',
|
||||
]],
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'current_operation_run_id' => (int) $run->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'last_seen_at' => now()->subMinutes(8),
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'raw_response_body' => 'stored-report-secret-body',
|
||||
],
|
||||
'fingerprint' => 'permission-fingerprint',
|
||||
]);
|
||||
|
||||
$evidenceSnapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'status' => 'active',
|
||||
'completeness_state' => 'complete',
|
||||
'summary' => [
|
||||
'dimension_count' => 1,
|
||||
'missing_dimensions' => 0,
|
||||
'stale_dimensions' => 0,
|
||||
],
|
||||
'generated_at' => now()->subMinutes(7),
|
||||
]);
|
||||
|
||||
$review = TenantReview::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $evidenceSnapshot->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'generated_at' => now()->subMinutes(7),
|
||||
]);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'generated_at' => now()->subMinutes(6),
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'action' => 'operation.failed',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => (string) $run->getKey(),
|
||||
'target_label' => 'Operation #'.$run->getKey(),
|
||||
'metadata' => [
|
||||
'raw_response_body' => 'audit-secret-body',
|
||||
'reason_code' => 'provider_permission_missing',
|
||||
],
|
||||
'outcome' => 'success',
|
||||
'recorded_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
operationSupportDiagnosticsComponent($user, $run)
|
||||
->assertActionVisible('openSupportDiagnostics')
|
||||
->assertActionEnabled('openSupportDiagnostics')
|
||||
->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getLabel() === 'Open support diagnostics')
|
||||
->assertActionHasIcon('openSupportDiagnostics', 'heroicon-o-lifebuoy')
|
||||
->mountAction('openSupportDiagnostics')
|
||||
->assertMountedActionModalSee('Support diagnostics')
|
||||
->assertMountedActionModalSee(OperationRunLinks::identifier($run))
|
||||
->assertMountedActionModalSee('The compare finished, but no decision-grade result is available yet.')
|
||||
->assertMountedActionModalSee('Contoso Microsoft connection')
|
||||
->assertMountedActionModalSee('High finding #'.$finding->getKey())
|
||||
->assertMountedActionModalSee('permission posture report')
|
||||
->assertMountedActionModalSee('Tenant review #'.$review->getKey())
|
||||
->assertMountedActionModalSee('Review pack #'.$pack->getKey())
|
||||
->assertMountedActionModalSee('Operation failed')
|
||||
->assertMountedActionModalSee('default-redacted')
|
||||
->assertMountedActionModalSee('[REDACTED]')
|
||||
->assertMountedActionModalDontSee('raw-provider-secret-message')
|
||||
->assertMountedActionModalDontSee('secret-provider-body')
|
||||
->assertMountedActionModalDontSee('stored-report-secret-body')
|
||||
->assertMountedActionModalDontSee('audit-secret-body');
|
||||
});
|
||||
|
||||
it('keeps tenantless operation detail deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function supportDiagnosticsTenantAuditComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||
}
|
||||
|
||||
function supportDiagnosticsOperationAuditComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||
|
||||
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||
}
|
||||
|
||||
function seedSupportDiagnosticsAuditFixture(string $role = 'owner'): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['name' => 'Audit Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: $role);
|
||||
|
||||
$connection = ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Audit connection',
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'raw-provider-secret-message',
|
||||
'last_health_check_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()
|
||||
->forTenant($tenant)
|
||||
->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
],
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'raw_response_body' => 'secret-provider-body',
|
||||
],
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'raw_response_body' => 'stored-report-secret-body',
|
||||
],
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'action' => 'operation.failed',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => (string) $run->getKey(),
|
||||
'target_label' => 'Operation #'.$run->getKey(),
|
||||
'metadata' => [
|
||||
'raw_response_body' => 'audit-secret-body',
|
||||
],
|
||||
'outcome' => 'success',
|
||||
'recorded_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
return [$user, $tenant, $run];
|
||||
}
|
||||
|
||||
it('audits tenant support diagnostics opens with redacted metadata and no side effects', function (): void {
|
||||
[$user, $tenant] = seedSupportDiagnosticsAuditFixture();
|
||||
|
||||
bindFailHardGraphClient();
|
||||
Bus::fake();
|
||||
Queue::fake();
|
||||
|
||||
$operationRunCount = OperationRun::query()->count();
|
||||
|
||||
assertNoOutboundHttp(function () use ($user, $tenant): void {
|
||||
supportDiagnosticsTenantAuditComponent($user, $tenant)
|
||||
->mountAction('openSupportDiagnostics')
|
||||
->assertMountedActionModalSee('Support diagnostics');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::SupportDiagnosticsOpened->value)
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
$metadataJson = json_encode($audit->metadata, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect(OperationRun::query()->count())->toBe($operationRunCount)
|
||||
->and(AuditLog::query()->where('action', AuditActionId::SupportDiagnosticsOpened->value)->count())->toBe(1)
|
||||
->and(AuditLog::query()->where('action', AuditActionId::BaselineCompareStarted->value)->count())->toBe(0)
|
||||
->and($audit->resource_type)->toBe('support_diagnostic_bundle')
|
||||
->and($audit->resource_id)->toBe('tenant:'.$tenant->getKey())
|
||||
->and($audit->operation_run_id)->toBeNull()
|
||||
->and($audit->metadata['context_type'] ?? null)->toBe('tenant')
|
||||
->and($audit->metadata['redaction_mode'] ?? null)->toBe('default_redacted')
|
||||
->and($audit->metadata['section_count'] ?? null)->toBe(8)
|
||||
->and($audit->metadata['reference_count'] ?? null)->toBeGreaterThan(0)
|
||||
->and($audit->metadata['primary_context_id'] ?? null)->toBe((string) $tenant->getKey())
|
||||
->and($metadataJson)->not->toContain('raw-provider-secret-message')
|
||||
->and($metadataJson)->not->toContain('secret-provider-body')
|
||||
->and($metadataJson)->not->toContain('stored-report-secret-body')
|
||||
->and($metadataJson)->not->toContain('audit-secret-body');
|
||||
});
|
||||
|
||||
it('audits operation support diagnostics opens with redacted metadata and no side effects', function (): void {
|
||||
[$user, $tenant, $run] = seedSupportDiagnosticsAuditFixture();
|
||||
|
||||
bindFailHardGraphClient();
|
||||
Bus::fake();
|
||||
Queue::fake();
|
||||
|
||||
$operationRunCount = OperationRun::query()->count();
|
||||
|
||||
assertNoOutboundHttp(function () use ($user, $run): void {
|
||||
supportDiagnosticsOperationAuditComponent($user, $run)
|
||||
->mountAction('openSupportDiagnostics')
|
||||
->assertMountedActionModalSee('Support diagnostics');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::SupportDiagnosticsOpened->value)
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
$metadataJson = json_encode($audit->metadata, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect(OperationRun::query()->count())->toBe($operationRunCount)
|
||||
->and(AuditLog::query()->where('action', AuditActionId::SupportDiagnosticsOpened->value)->count())->toBe(1)
|
||||
->and(AuditLog::query()->where('action', AuditActionId::BaselineCompareStarted->value)->count())->toBe(0)
|
||||
->and($audit->resource_type)->toBe('support_diagnostic_bundle')
|
||||
->and($audit->resource_id)->toBe('operation_run:'.$run->getKey())
|
||||
->and($audit->operation_run_id)->toBe((int) $run->getKey())
|
||||
->and($audit->metadata['context_type'] ?? null)->toBe('operation_run')
|
||||
->and($audit->metadata['redaction_mode'] ?? null)->toBe('default_redacted')
|
||||
->and($audit->metadata['section_count'] ?? null)->toBe(8)
|
||||
->and($audit->metadata['reference_count'] ?? null)->toBeGreaterThan(0)
|
||||
->and($audit->metadata['primary_context_id'] ?? null)->toBe((string) $run->getKey())
|
||||
->and($metadataJson)->not->toContain('raw-provider-secret-message')
|
||||
->and($metadataJson)->not->toContain('secret-provider-body')
|
||||
->and($metadataJson)->not->toContain('stored-report-secret-body')
|
||||
->and($metadataJson)->not->toContain('audit-secret-body');
|
||||
});
|
||||
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function supportDiagnosticsTenantAuthorizationComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||
}
|
||||
|
||||
function supportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||
|
||||
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||
}
|
||||
|
||||
it('keeps tenant support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns forbidden for entitled tenant members without support diagnostics capability', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
supportDiagnosticsTenantAuthorizationComponent($user, $tenant)
|
||||
->assertActionVisible('openSupportDiagnostics')
|
||||
->assertActionDisabled('openSupportDiagnostics')
|
||||
->call('tenantSupportDiagnosticBundle')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns forbidden for entitled run viewers without support diagnostics capability', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
supportDiagnosticsOperationAuthorizationComponent($user, $run)
|
||||
->assertActionVisible('openSupportDiagnostics')
|
||||
->assertActionDisabled('openSupportDiagnostics')
|
||||
->call('operationRunSupportDiagnosticBundle')
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function tenantSupportDiagnosticsComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
test()->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||
}
|
||||
|
||||
it('opens a redacted tenant support diagnostic bundle from the tenant dashboard', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||
|
||||
$connection = ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Contoso Microsoft connection',
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'raw-provider-secret-message',
|
||||
'last_health_check_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()
|
||||
->forTenant($tenant)
|
||||
->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'raw_response_body' => 'secret-provider-body',
|
||||
],
|
||||
'failure_summary' => [[
|
||||
'message' => 'Compare failed after provider permission validation.',
|
||||
]],
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'current_operation_run_id' => (int) $run->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'last_seen_at' => now()->subMinutes(8),
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'raw_response_body' => 'stored-report-secret-body',
|
||||
],
|
||||
'fingerprint' => 'permission-fingerprint',
|
||||
]);
|
||||
|
||||
$evidenceSnapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'status' => 'active',
|
||||
'completeness_state' => 'complete',
|
||||
'summary' => [
|
||||
'dimension_count' => 1,
|
||||
'missing_dimensions' => 0,
|
||||
'stale_dimensions' => 0,
|
||||
],
|
||||
'generated_at' => now()->subMinutes(7),
|
||||
]);
|
||||
|
||||
$review = TenantReview::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $evidenceSnapshot->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => TenantReviewStatus::Ready->value,
|
||||
'generated_at' => now()->subMinutes(7),
|
||||
]);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'generated_at' => now()->subMinutes(6),
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'action' => 'operation.failed',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => (string) $run->getKey(),
|
||||
'target_label' => 'Operation #'.$run->getKey(),
|
||||
'metadata' => [
|
||||
'raw_response_body' => 'audit-secret-body',
|
||||
'reason_code' => 'provider_permission_missing',
|
||||
],
|
||||
'outcome' => 'success',
|
||||
'recorded_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
tenantSupportDiagnosticsComponent($user, $tenant)
|
||||
->assertActionVisible('openSupportDiagnostics')
|
||||
->assertActionEnabled('openSupportDiagnostics')
|
||||
->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getLabel() === 'Open support diagnostics')
|
||||
->mountAction('openSupportDiagnostics')
|
||||
->assertMountedActionModalSee('Support diagnostics')
|
||||
->assertMountedActionModalSee('Contoso Support Tenant')
|
||||
->assertMountedActionModalSee('Permissions missing')
|
||||
->assertMountedActionModalSee('provider app is missing required Microsoft Graph permissions')
|
||||
->assertMountedActionModalSee('Operation #'.$run->getKey())
|
||||
->assertMountedActionModalSee('High finding #'.$finding->getKey())
|
||||
->assertMountedActionModalSee('permission posture report')
|
||||
->assertMountedActionModalSee('Tenant review #'.$review->getKey())
|
||||
->assertMountedActionModalSee('Review pack #'.$pack->getKey())
|
||||
->assertMountedActionModalSee('Operation failed')
|
||||
->assertMountedActionModalSee('default-redacted')
|
||||
->assertMountedActionModalSee('[REDACTED]')
|
||||
->assertMountedActionModalDontSee('raw-provider-secret-message')
|
||||
->assertMountedActionModalDontSee('secret-provider-body')
|
||||
->assertMountedActionModalDontSee('stored-report-secret-body')
|
||||
->assertMountedActionModalDontSee('audit-secret-body');
|
||||
});
|
||||
|
||||
it('denies non-entitled tenant dashboard access as not found', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('shows support diagnostics as disabled for entitled members without the support capability', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
tenantSupportDiagnosticsComponent($user, $tenant)
|
||||
->assertActionVisible('openSupportDiagnostics')
|
||||
->assertActionDisabled('openSupportDiagnostics')
|
||||
->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
});
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('builds tenant bundles with stable section order and stable reference order', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Deterministic Tenant']);
|
||||
|
||||
ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Deterministic connection',
|
||||
]);
|
||||
|
||||
OperationRun::factory()
|
||||
->forTenant($tenant)
|
||||
->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$firstFinding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'severity' => Finding::SEVERITY_LOW,
|
||||
'last_seen_at' => now()->subMinutes(4),
|
||||
]);
|
||||
|
||||
$secondFinding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'last_seen_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
$builder = app(SupportDiagnosticBundleBuilder::class);
|
||||
|
||||
$firstBundle = $builder->forTenant($tenant);
|
||||
$secondBundle = $builder->forTenant($tenant->fresh());
|
||||
|
||||
expect(array_column($firstBundle['sections'], 'key'))
|
||||
->toBe([
|
||||
'overview',
|
||||
'provider_connection',
|
||||
'operation_context',
|
||||
'findings',
|
||||
'stored_reports',
|
||||
'tenant_review',
|
||||
'review_pack',
|
||||
'audit_history',
|
||||
])
|
||||
->and(array_column($firstBundle['sections'], 'key'))->toBe(array_column($secondBundle['sections'], 'key'));
|
||||
|
||||
$firstFindingsSection = collect($firstBundle['sections'])->firstWhere('key', 'findings');
|
||||
$secondFindingsSection = collect($secondBundle['sections'])->firstWhere('key', 'findings');
|
||||
|
||||
expect(array_column($firstFindingsSection['references'], 'record_id'))
|
||||
->toBe([(string) $firstFinding->getKey(), (string) $secondFinding->getKey()])
|
||||
->and($firstFindingsSection['references'])->toBe($secondFindingsSection['references']);
|
||||
});
|
||||
|
||||
it('degrades missing operation-run support context without failing bundle generation', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$tenantlessRun = OperationRun::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$bundle = app(SupportDiagnosticBundleBuilder::class)->forOperationRun($tenantlessRun);
|
||||
$sections = collect($bundle['sections'])->keyBy('key');
|
||||
|
||||
expect($bundle['context']['tenant_id'])->toBeNull()
|
||||
->and($bundle['summary']['completeness_note'])->toContain('Provider connection')
|
||||
->and($bundle['summary']['completeness_note'])->toContain('Review pack')
|
||||
->and($sections['operation_context']['availability'])->toBe('available')
|
||||
->and($sections['provider_connection']['availability'])->toBe('missing')
|
||||
->and($sections['findings']['availability'])->toBe('missing')
|
||||
->and($sections['stored_reports']['availability'])->toBe('missing')
|
||||
->and($sections['tenant_review']['availability'])->toBe('missing')
|
||||
->and($sections['review_pack']['availability'])->toBe('missing');
|
||||
});
|
||||
|
||||
it('marks references without canonical destinations as inaccessible', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$method = new \ReflectionMethod(SupportDiagnosticBundleBuilder::class, 'modelReference');
|
||||
$reference = $method->invoke(
|
||||
app(SupportDiagnosticBundleBuilder::class),
|
||||
'provider_connection',
|
||||
$connection,
|
||||
'Detached provider connection',
|
||||
'Open provider connection',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
expect($reference)
|
||||
->toBeArray()
|
||||
->and($reference['availability'])->toBe('inaccessible')
|
||||
->and($reference['access_reason'])->toBe('Canonical destination is not available from this context.');
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('includes translated provider reasons and explicit support diagnostic redaction markers', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Redaction Tenant']);
|
||||
|
||||
ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Redaction connection',
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'provider-secret-message',
|
||||
'last_health_check_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$bundle = app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant);
|
||||
$providerSection = collect($bundle['sections'])->firstWhere('key', 'provider_connection');
|
||||
|
||||
expect($bundle['redaction_mode'])->toBe('default_redacted')
|
||||
->and($bundle['summary']['redaction_note'])->toBe(RedactionIntegrity::supportDiagnosticsNote())
|
||||
->and($providerSection['summary'])->toContain('provider app is missing required Microsoft Graph permissions')
|
||||
->and(collect($bundle['redaction']['markers'])->pluck('path')->all())
|
||||
->toContain('provider_connection.credential', 'stored_reports.payload', 'audit_logs.metadata.raw')
|
||||
->and(collect($providerSection['redaction_markers'])->pluck('path')->all())
|
||||
->toContain('provider_connection.credential');
|
||||
});
|
||||
|
||||
it('excludes raw provider payloads and unrestricted log excerpts from support bundles', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Secret-Free Tenant']);
|
||||
|
||||
$connection = ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'raw-provider-secret-message',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()
|
||||
->forTenant($tenant)
|
||||
->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'raw_response_body' => 'secret-provider-body',
|
||||
],
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
],
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'raw_response_body' => 'stored-report-secret-body',
|
||||
],
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'action' => 'operation.failed',
|
||||
'resource_type' => 'operation_run',
|
||||
'resource_id' => (string) $run->getKey(),
|
||||
'target_label' => 'Operation #'.$run->getKey(),
|
||||
'metadata' => [
|
||||
'raw_response_body' => 'audit-secret-body',
|
||||
],
|
||||
'outcome' => 'success',
|
||||
'recorded_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$bundleJson = json_encode(app(SupportDiagnosticBundleBuilder::class)->forOperationRun($run), JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($bundleJson)
|
||||
->not->toContain('raw-provider-secret-message')
|
||||
->not->toContain('secret-provider-body')
|
||||
->not->toContain('stored-report-secret-body')
|
||||
->not->toContain('audit-secret-body')
|
||||
->toContain('default_redacted')
|
||||
->toContain('[REDACTED]');
|
||||
});
|
||||
@ -62,6 +62,7 @@ ## Promoted to Spec
|
||||
- Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`)
|
||||
- Operation Run Active-State Visibility & Stale Escalation → Spec 233 (`stale-run-visibility`)
|
||||
- Provider Boundary Hardening → Spec 237 (`provider-boundary-hardening`)
|
||||
- Support Diagnostic Pack → Spec 241 (`support-diagnostic-pack`)
|
||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||
@ -80,20 +81,19 @@ ## Qualified
|
||||
>
|
||||
> Recommended next sequence:
|
||||
>
|
||||
> 1. **Self-Service Tenant Onboarding & Connection Readiness**
|
||||
> 2. **Support Diagnostic Pack**
|
||||
> 3. **Product Usage & Adoption Telemetry**
|
||||
> 4. **Operational Controls & Feature Flags**
|
||||
> 5. **Private AI Execution & Policy Foundation**
|
||||
> 6. **AI Usage Budgeting, Context & Result Governance**
|
||||
> 7. **Decision-Based Governance Inbox v1**
|
||||
> 8. **Decision Pack Contract & Approval Workflow**
|
||||
> 9. **Provider Identity & Target Scope Neutrality**
|
||||
> 10. **Canonical Operation Type Source of Truth**
|
||||
> 11. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||
> 12. **Customer Review Workspace v1**
|
||||
> 1. **Self-Service Tenant Onboarding & Connection Readiness** (already promoted as Spec 240 on its feature branch)
|
||||
> 2. **Product Usage & Adoption Telemetry**
|
||||
> 3. **Operational Controls & Feature Flags**
|
||||
> 4. **Private AI Execution & Policy Foundation**
|
||||
> 5. **AI Usage Budgeting, Context & Result Governance**
|
||||
> 6. **Decision-Based Governance Inbox v1**
|
||||
> 7. **Decision Pack Contract & Approval Workflow**
|
||||
> 8. **Provider Identity & Target Scope Neutrality**
|
||||
> 9. **Canonical Operation Type Source of Truth**
|
||||
> 10. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||
> 11. **Customer Review Workspace v1**
|
||||
>
|
||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. Self-service onboarding, diagnostic packs, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces.
|
||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. With self-service onboarding already promoted as Spec 240 and Support Diagnostic Pack now promoted as Spec 241, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox become the next open priorities so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces.
|
||||
|
||||
|
||||
> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track.
|
||||
@ -128,36 +128,6 @@ ### Self-Service Tenant Onboarding & Connection Readiness
|
||||
- **Strategic sequencing**: First item in this product-scalability cluster because it directly reduces manual onboarding and supports trials, demos, support, and customer transparency.
|
||||
- **Priority**: high
|
||||
|
||||
### Support Diagnostic Pack
|
||||
- **Type**: product scalability / supportability foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: Support cases currently risk requiring manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, Report, Evidence, and audit surfaces. Without a reusable diagnostic bundle, every support request becomes an investigation task before the actual issue can be addressed.
|
||||
- **Why it matters**: A low-headcount SaaS needs support context to be captured by the product, not reconstructed by the founder. Diagnostic packs also create the safe input layer for later AI-assisted support summaries and triage without granting an AI or support user broad ad-hoc access to everything.
|
||||
- **Proposed direction**:
|
||||
- define a support diagnostic bundle contract for workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, and review-pack contexts
|
||||
- include relevant health state, latest operation links, failure reason codes, permission/connection state, freshness, artifact references, audit references, and redacted operator summaries
|
||||
- provide an AI-readable but customer-safe summary shape that can be attached to support requests
|
||||
- keep raw sensitive payloads out of the default pack unless explicitly authorized
|
||||
- model redaction and access checks as first-class behavior
|
||||
- allow diagnostic packs to be referenced from in-app support requests and internal support workflows
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: diagnostic pack contract, context collectors, redaction rules, support-safe summary generation, access policy, references to runs/findings/reports/evidence, and tests
|
||||
- **Out of scope**: external ticket-system integration, support desk implementation, AI chat bot, broad log export, customer-visible trust center, or unrestricted raw payload download
|
||||
- **Acceptance points**:
|
||||
- a diagnostic pack can be generated for at least tenant and OperationRun contexts
|
||||
- pack contents are deterministic, scoped, and redacted according to caller capability
|
||||
- the pack links to canonical OperationRun/report/finding/evidence records instead of duplicating truth
|
||||
- sensitive raw provider payloads are excluded by default
|
||||
- tests prove unauthorized users cannot generate packs for unrelated workspaces/tenants
|
||||
- **Risks / open questions**:
|
||||
- Over-including raw context could create data-leak or compliance risk
|
||||
- Under-including context would make the pack less useful and push operators back to manual investigation
|
||||
- The product needs a clear capability boundary, likely related to `platform.support_diagnostics.view` and tenant/workspace support permissions
|
||||
- **Dependencies**: OperationRun link contract, StoredReports / EvidenceItems, Findings workflow, ProviderConnection health, audit log foundation, System Panel least-privilege model
|
||||
- **Related specs / candidates**: In-App Support Request with Context, AI-Assisted Customer Operations, System Panel Least-Privilege Capability Model, OperationRun Start UX Contract
|
||||
- **Strategic sequencing**: Second item after Self-Service Tenant Onboarding; it should land before support volume grows and before AI support triage is introduced.
|
||||
- **Priority**: high
|
||||
|
||||
### In-App Support Request with Context
|
||||
- **Type**: product scalability / support workflow
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
@ -330,8 +300,8 @@ ### AI-Assisted Customer Operations
|
||||
- AI hallucination risk must be mitigated through structured inputs and source references
|
||||
- Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider
|
||||
- The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable
|
||||
- **Dependencies**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review
|
||||
- **Related specs / candidates**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light
|
||||
- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review
|
||||
- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light
|
||||
- **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist.
|
||||
- **Priority**: medium
|
||||
|
||||
@ -433,7 +403,7 @@ ### Operational Controls & Feature Flags
|
||||
- Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower
|
||||
- Too many flags can create configuration drift; start with high-risk controls only
|
||||
- Read-only modes need careful definition so evidence/audit access remains available
|
||||
- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, AI execution controls, audit log foundation, Plans / Entitlements & Billing Readiness
|
||||
- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness
|
||||
- **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan
|
||||
- **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate.
|
||||
- **Priority**: high
|
||||
@ -676,7 +646,7 @@ ### Decision Pack Contract & Approval Workflow
|
||||
- Too much context can overwhelm operators; the pack must be concise with progressive disclosure
|
||||
- Recommendations must not overstate certainty; confidence/freshness must be visible
|
||||
- AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature
|
||||
- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation
|
||||
- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation
|
||||
- **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication
|
||||
- **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready.
|
||||
- **Priority**: high
|
||||
|
||||
35
specs/241-support-diagnostic-pack/checklists/requirements.md
Normal file
35
specs/241-support-diagnostic-pack/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Support Diagnostic Pack
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-25
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-04-25 against the repo template, constitution, and spec approval rubric.
|
||||
- The first slice stays explicitly narrow: tenant context and OperationRun context only, derived references only, redaction and access checks first, no new support-pack persistence, and no external helpdesk or AI behavior.
|
||||
@ -0,0 +1,284 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin — Support Diagnostic Bundle (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual HTTP contract for the first support-diagnostics slice.
|
||||
|
||||
NOTE: These flows are implemented as Filament (Livewire) header actions on
|
||||
existing pages. The exact Livewire request payload is not part of this
|
||||
contract; this file captures the user-visible surfaces, authorization
|
||||
semantics, and the derived bundle view model.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/t/{tenant}/support-diagnostics/actions/open:
|
||||
post:
|
||||
summary: Open support diagnostics from the tenant dashboard
|
||||
description: |
|
||||
Read-only support-diagnostic action on the existing tenant dashboard.
|
||||
|
||||
Authorization:
|
||||
- Workspace non-member or non-entitled tenant actor: 404
|
||||
- Entitled tenant member without `support_diagnostics.view`: 403
|
||||
- Authorized actor: 200 with a derived, redacted support bundle
|
||||
|
||||
Behavior:
|
||||
- No persisted support pack is created
|
||||
- Rendering stays DB-only for this slice and performs no outbound HTTP
|
||||
- No provider-backed work, queue work, or new `OperationRun` is dispatched
|
||||
- No raw provider payload or unrestricted log export is returned
|
||||
- One redacted audit entry is recorded for bundle-open activity
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Filament tenancy slug (`tenants.external_id`)
|
||||
responses:
|
||||
'200':
|
||||
description: Support diagnostic preview rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/SupportDiagnosticBundleView'
|
||||
'403':
|
||||
description: Forbidden (entitled tenant member lacks support-diagnostics capability)
|
||||
'404':
|
||||
description: Not found (wrong workspace, non-member, or missing tenant entitlement)
|
||||
/operations/{run}/support-diagnostics/actions/open:
|
||||
post:
|
||||
summary: Open support diagnostics from the canonical operation detail page
|
||||
description: |
|
||||
Read-only support-diagnostic action on the canonical tenantless operation
|
||||
detail viewer.
|
||||
|
||||
Authorization:
|
||||
- Inaccessible run under `OperationRunPolicy`: 404
|
||||
- Authorized member without `support_diagnostics.view`: 403
|
||||
- Authorized actor: 200 with a derived, redacted support bundle
|
||||
|
||||
Behavior:
|
||||
- The action is only available when the canonical run detail resolves to an entitled tenant scope
|
||||
- Reuses existing humanized operation explanation and canonical links
|
||||
- Rendering stays DB-only for this slice and performs no outbound HTTP
|
||||
- Does not create, update, or complete an `OperationRun`
|
||||
- Does not dispatch provider-backed work, queue work, or queued operation UX side effects
|
||||
- Records one redacted audit entry for bundle-open activity
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Internal `operation_runs.id`
|
||||
responses:
|
||||
'200':
|
||||
description: Support diagnostic preview rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/SupportDiagnosticBundleView'
|
||||
'403':
|
||||
description: Forbidden (authorized member lacks support-diagnostics capability)
|
||||
'404':
|
||||
description: Not found (run inaccessible under workspace or tenant scope)
|
||||
components:
|
||||
schemas:
|
||||
SupportDiagnosticBundleView:
|
||||
type: object
|
||||
required:
|
||||
- context
|
||||
- summary
|
||||
- sections
|
||||
- redaction
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SupportDiagnosticContext'
|
||||
summary:
|
||||
$ref: '#/components/schemas/SupportDiagnosticSummary'
|
||||
sections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SupportDiagnosticSection'
|
||||
redaction:
|
||||
$ref: '#/components/schemas/SupportDiagnosticRedaction'
|
||||
notes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
audit:
|
||||
type: object
|
||||
required:
|
||||
- action
|
||||
- outcome
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
SupportDiagnosticContext:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- workspace_id
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [tenant, operation_run]
|
||||
workspace_id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
tenant_label:
|
||||
type: string
|
||||
nullable: true
|
||||
workspace_label:
|
||||
type: string
|
||||
nullable: true
|
||||
SupportDiagnosticSummary:
|
||||
type: object
|
||||
required:
|
||||
- headline
|
||||
- dominant_issue
|
||||
- freshness_state
|
||||
- redaction_note
|
||||
properties:
|
||||
headline:
|
||||
type: string
|
||||
dominant_issue:
|
||||
type: string
|
||||
freshness_state:
|
||||
type: string
|
||||
enum: [fresh, stale, mixed, missing_context]
|
||||
completeness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
redaction_note:
|
||||
type: string
|
||||
generated_from:
|
||||
type: string
|
||||
enum: [derived_existing_truth]
|
||||
SupportDiagnosticSection:
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- availability
|
||||
- summary
|
||||
- references
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
enum:
|
||||
- overview
|
||||
- provider_connection
|
||||
- operation_context
|
||||
- findings
|
||||
- stored_reports
|
||||
- tenant_review
|
||||
- review_pack
|
||||
- audit_history
|
||||
label:
|
||||
type: string
|
||||
availability:
|
||||
type: string
|
||||
enum: [available, missing, stale, inaccessible, redacted]
|
||||
summary:
|
||||
type: string
|
||||
freshness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
references:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SupportDiagnosticReference'
|
||||
redaction_markers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedactionMarker'
|
||||
SupportDiagnosticReference:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- label
|
||||
- action_label
|
||||
- availability
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- tenant
|
||||
- operation_run
|
||||
- provider_connection
|
||||
- finding
|
||||
- stored_report
|
||||
- tenant_review
|
||||
- review_pack
|
||||
- audit_log
|
||||
record_id:
|
||||
type: string
|
||||
nullable: true
|
||||
label:
|
||||
type: string
|
||||
action_label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
nullable: true
|
||||
availability:
|
||||
type: string
|
||||
enum: [available, missing, inaccessible]
|
||||
freshness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
access_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
SupportDiagnosticRedaction:
|
||||
type: object
|
||||
required:
|
||||
- mode
|
||||
- markers
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [default_redacted]
|
||||
markers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedactionMarker'
|
||||
RedactionMarker:
|
||||
type: object
|
||||
required:
|
||||
- reason
|
||||
- replacement_text
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
nullable: true
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- secret
|
||||
- credential
|
||||
- raw_payload
|
||||
- restricted_log_excerpt
|
||||
- inaccessible_record
|
||||
replacement_text:
|
||||
type: string
|
||||
294
specs/241-support-diagnostic-pack/data-model.md
Normal file
294
specs/241-support-diagnostic-pack/data-model.md
Normal file
@ -0,0 +1,294 @@
|
||||
# Data Model — Support Diagnostic Pack
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
No new persistent tables are required for the first support-diagnostics slice. The bundle is computed at request time from existing canonical records.
|
||||
|
||||
## Existing Canonical Entities Reused
|
||||
|
||||
### Workspace (`workspaces`)
|
||||
|
||||
**Purpose**: Primary admin-plane isolation boundary and audit scope for every support-diagnostic bundle.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `name`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies the workspace scope label.
|
||||
- Anchors workspace-membership checks.
|
||||
- Owns audit-log scope for bundle-open activity.
|
||||
|
||||
### Tenant (`tenants`)
|
||||
|
||||
**Purpose**: Tenant-plane scope boundary and the canonical subject for tenant-context support diagnostics.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
- `name`
|
||||
- `status`
|
||||
|
||||
**Bundle use**:
|
||||
- Acts as the primary subject for tenant-context bundles.
|
||||
- Supplies tenant identity and tenant authorization scope.
|
||||
|
||||
### OperationRun (`operation_runs`)
|
||||
|
||||
**Purpose**: Canonical execution truth and the primary subject for operation-context support diagnostics.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id` (nullable)
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `summary_counts`
|
||||
- `context`
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `workspace()`
|
||||
- `user()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies the primary execution summary.
|
||||
- Carries run-bound reference ids such as `provider_connection_id` and artifact references in `context`.
|
||||
- Reuses existing humanized run explanation and canonical run URLs.
|
||||
|
||||
### ProviderConnection (`provider_connections`)
|
||||
|
||||
**Purpose**: Canonical provider readiness and connection state for the tenant or run context.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `provider`
|
||||
- `connection_type`
|
||||
- `consent_status`
|
||||
- `verification_status`
|
||||
- `last_error_reason_code`
|
||||
- `last_error_message`
|
||||
- `is_default`
|
||||
- `is_enabled`
|
||||
- `last_health_check_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `workspace()`
|
||||
- `credential()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies provider readiness summary, translated provider failure reasons, and target-scope detail.
|
||||
- Never contributes raw credential payloads or secrets to the bundle.
|
||||
|
||||
### Finding (`findings`)
|
||||
|
||||
**Purpose**: Canonical drift or permission posture issues that may explain current support pressure.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `baseline_operation_run_id`
|
||||
- `current_operation_run_id`
|
||||
- `due_at`
|
||||
- `last_seen_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `baselineRun()`
|
||||
- `currentRun()`
|
||||
- `findingException()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies prioritized open or recent findings relevant to the current tenant or run.
|
||||
- Contributes summary and freshness cues only; finding detail remains on canonical pages.
|
||||
|
||||
### StoredReport (`stored_reports`)
|
||||
|
||||
**Purpose**: Canonical report/evidence truth for report identity and freshness.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `report_type`
|
||||
- `fingerprint`
|
||||
- `previous_fingerprint`
|
||||
- `payload`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `workspace()`
|
||||
- `tenant()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies report identity, report type, and freshness/continuity cues.
|
||||
- The bundle must not expose the full stored report payload by default.
|
||||
|
||||
### TenantReview (`tenant_reviews`)
|
||||
|
||||
**Purpose**: Canonical tenant review state and review-level summary for governance follow-up.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `operation_run_id`
|
||||
- `status`
|
||||
- `completeness_state`
|
||||
- `summary`
|
||||
- `generated_at`
|
||||
- `current_export_review_pack_id`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `workspace()`
|
||||
- `tenant()`
|
||||
- `operationRun()`
|
||||
- `reviewPacks()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies current review status, completeness, blockers, and canonical review references when review truth is relevant.
|
||||
|
||||
### ReviewPack (`review_packs`)
|
||||
|
||||
**Purpose**: Canonical review export/package truth when a tenant review already has a pack.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `operation_run_id`
|
||||
- `tenant_review_id`
|
||||
- `status`
|
||||
- `summary`
|
||||
- `generated_at`
|
||||
- `expires_at`
|
||||
- `file_size`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `workspace()`
|
||||
- `tenant()`
|
||||
- `operationRun()`
|
||||
- `tenantReview()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies pack availability, readiness, and expiry cues.
|
||||
- The bundle links to the canonical pack viewer instead of reproducing pack content.
|
||||
|
||||
### AuditLog (`audit_logs`)
|
||||
|
||||
**Purpose**: Canonical audit trail for workspace-, tenant-, and operation-related events.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `action`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `target_label`
|
||||
- `metadata`
|
||||
- `outcome`
|
||||
- `operation_run_id`
|
||||
- `recorded_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `workspace()`
|
||||
- `operationRun()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies the most relevant authorized audit references for the current tenant or run.
|
||||
- Also records bundle-open activity with redacted metadata only.
|
||||
|
||||
## Derived Runtime Entities
|
||||
|
||||
### SupportDiagnosticBundle (computed, not persisted)
|
||||
|
||||
**Purpose**: One machine-readable, redacted support-safe envelope for either a tenant context or an operation-run context.
|
||||
|
||||
**Proposed shape (runtime array / view-model)**:
|
||||
- `context_type` — `tenant` or `operation_run`
|
||||
- `workspace` — workspace reference and label
|
||||
- `tenant` — tenant reference when applicable
|
||||
- `operation_run` — primary run reference when applicable
|
||||
- `headline` — dominant support summary
|
||||
- `dominant_issue` — translated blocker or issue statement
|
||||
- `freshness_state` — derived cue such as `fresh`, `stale`, `mixed`, or `missing_context`
|
||||
- `redaction_mode` — fixed first-slice mode: default-redacted
|
||||
- `sections` — ordered list of section payloads
|
||||
- `notes` — explicit redaction, completeness, or degradation notes
|
||||
|
||||
**Relationships**:
|
||||
- 1 workspace
|
||||
- 0..1 tenant
|
||||
- 0..1 primary operation run
|
||||
- 0..1 provider connection section
|
||||
- 0..n finding references
|
||||
- 0..n stored report references
|
||||
- 0..1 tenant review reference
|
||||
- 0..1 review pack reference
|
||||
- 0..n audit references
|
||||
|
||||
### SupportDiagnosticSection (computed, not persisted)
|
||||
|
||||
**Purpose**: One deterministic section inside the bundle.
|
||||
|
||||
**Proposed shape**:
|
||||
- `key` — fixed section key such as `provider_connection`, `operation_context`, `findings`, `stored_reports`, `tenant_review`, `review_pack`, `audit_history`
|
||||
- `label`
|
||||
- `availability` — derived local status (`available`, `missing`, `stale`, `inaccessible`, `redacted`)
|
||||
- `summary`
|
||||
- `freshness_at` or `freshness_note`
|
||||
- `references` — ordered support references for that section
|
||||
- `redaction_markers` — explicit markers when detail is intentionally excluded
|
||||
|
||||
**Note**: these are presentation-contract values only, not new persisted domain state.
|
||||
|
||||
### SupportDiagnosticReference (computed, not persisted)
|
||||
|
||||
**Purpose**: Stable canonical link/reference metadata for one related record.
|
||||
|
||||
**Proposed shape**:
|
||||
- `type` — `tenant`, `operation_run`, `provider_connection`, `finding`, `stored_report`, `tenant_review`, `review_pack`, or `audit_log`
|
||||
- `record_id`
|
||||
- `label`
|
||||
- `action_label`
|
||||
- `url` (nullable when inaccessible or no viewer exists)
|
||||
- `availability`
|
||||
- `freshness_note` (nullable)
|
||||
- `access_reason` or `missing_reason` (nullable)
|
||||
|
||||
### RedactionMarker (computed, not persisted)
|
||||
|
||||
**Purpose**: Make exclusion deterministic and explicit.
|
||||
|
||||
**Proposed shape**:
|
||||
- `path` (nullable)
|
||||
- `reason` — `secret`, `credential`, `raw_payload`, `restricted_log_excerpt`, or `inaccessible_record`
|
||||
- `replacement_text`
|
||||
|
||||
## Derived Rules and Validation
|
||||
|
||||
- Bundle generation requires established workspace membership first.
|
||||
- Tenant-context bundle generation requires tenant entitlement before any tenant-owned section resolves.
|
||||
- Operation-context bundle generation must first pass `OperationRunPolicy::view(...)`; if the run points at a tenant, tenant entitlement still applies before tenant-owned related records resolve.
|
||||
- After membership/entitlement is established, missing `support_diagnostics.view` is a `403` capability denial.
|
||||
- The bundle must never include secrets, tokens, credentials, unrestricted provider response bodies, or unrestricted stored-report payloads.
|
||||
- Missing or inaccessible related records must degrade into explicit placeholders or unavailable references, not hard-fail the whole bundle.
|
||||
- For unchanged authorized input, section order, reference order, and redaction output must remain stable.
|
||||
|
||||
## Lifecycle and Audit Behavior
|
||||
|
||||
- `SupportDiagnosticBundle` has no persisted lifecycle.
|
||||
- Opening the bundle writes one audit event with redacted metadata only.
|
||||
- Opening linked canonical records follows their own existing authorization and audit behavior.
|
||||
214
specs/241-support-diagnostic-pack/plan.md
Normal file
214
specs/241-support-diagnostic-pack/plan.md
Normal file
@ -0,0 +1,214 @@
|
||||
# Implementation Plan: Support Diagnostic Pack
|
||||
|
||||
**Branch**: `241-support-diagnostic-pack` | **Date**: 2026-04-25 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Add one derived, support-safe diagnostic bundle contract that can be opened from exactly two existing admin-plane surfaces: the tenant dashboard and the canonical tenantless operation detail viewer.
|
||||
- Reuse current canonical truth and shared paths instead of inventing support-local persistence or navigation: `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, `AuditLog`, `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, and the existing audit recorder.
|
||||
- Keep the first slice strictly read-only and deterministic: no support-desk workflow, no external ticketing, no raw payload export, no persisted support-pack entity, no new `OperationRun` type, no notification changes, and no AI runtime behavior.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||
**Storage**: PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned
|
||||
**Testing**: Pest unit + feature tests only
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}`
|
||||
**Project Type**: web
|
||||
**Performance Goals**: bundle collection and preview remain DB-only at render/action time, perform no outbound HTTP, create no new `OperationRun`, and deterministically compose the same result for unchanged authorized input
|
||||
**Constraints**: derive-before-persist, provider-neutral top-level language, deterministic redaction, no raw payload/body export, no support-desk product, no external ticketing, no AI runtime features, no new run lifecycle semantics, no browser/heavy-governance drift, and RBAC non-member `404` versus capability `403` boundaries must remain explicit
|
||||
**Scale/Scope**: one bundle contract, two entry contexts (`TenantDashboard` and `TenantlessOperationRunViewer`), existing canonical record references only, and focused unit + feature coverage
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + shared read-only preview composition
|
||||
- **Shared-family relevance**: header actions, diagnostic summaries, related links, audit drill-throughs
|
||||
- **State layers in scope**: page, detail, action preview
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
||||
- **Required tests or manual smoke**: functional-core
|
||||
- **Exception path and spread control**: the existing tenant dashboard action-surface exemption remains the only dashboard exception; no new support page, queue, or custom interaction family is introduced
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\OperationRunLinks`, `App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder`, `App\Support\Providers\ProviderReasonTranslator`, `App\Support\Navigation\RelatedNavigationResolver`, `App\Support\RedactionIntegrity`, canonical review/provider/finding/audit viewers, and `App\Services\Audit\WorkspaceAuditLogger`
|
||||
- **Shared abstractions reused**: `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId`
|
||||
- **New abstraction introduced? why?**: one narrow `SupportDiagnosticBundleBuilder` under `App\Support\SupportDiagnostics` is justified because there are now two concrete contexts that must emit the same machine-readable, redacted bundle contract. It should return one documented array shape, not a registry, strategy layer, or persisted model.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: existing helpers already own canonical links, run explanation language, provider reason translation, and redaction-note wording; what is missing is a deterministic cross-record composition layer that assembles those existing truths into one support-safe bundle.
|
||||
- **Bounded deviation / spread control**: preview stays on the two existing pages only, reuses current labels and URLs, and explicitly forbids a standalone support resource, export pipeline, or page-local link dialect.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for deep-link and explanation reuse only
|
||||
- **Central contract reused**: `OperationRunLinks` and the existing canonical tenantless run viewer, plus `GovernanceRunDiagnosticSummaryBuilder` for run explanation reuse
|
||||
- **Delegated UX behaviors**: canonical `Open operation` labeling, tenant/workspace-safe run URL resolution, shared run explanation language, and existing related-record link labels
|
||||
- **Surface-owned behavior kept local**: one read-only `Open support diagnostics` action plus preview/detail composition on the tenant dashboard and run-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?**: yes
|
||||
- **Provider-owned seams**: provider connection descriptors, consent/permission failure excerpts, translated provider error detail
|
||||
- **Platform-core seams**: bundle shell, context labels, section ordering, freshness cues, redaction semantics, and canonical-reference vocabulary
|
||||
- **Neutral platform terms / contracts preserved**: `support diagnostic bundle`, `tenant context`, `operation context`, `provider connection`, `related record`, `audit reference`, `redaction reason`
|
||||
- **Retained provider-specific semantics and why**: Microsoft-specific permission, consent, and provider-failure wording stays only inside translated provider-owned summaries produced by `ProviderReasonTranslator`, because the current operator still needs exact remediation context when provider readiness is the blocker
|
||||
- **Bounded extraction or follow-up path**: none in this slice; any future System Panel least-privilege support surface remains a separate follow-up spec instead of widening this tenant/admin-plane bundle contract
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Derive-before-persist / `PERSIST-001`: PASS — no `support_diagnostic_packs` table or persisted entity is introduced; the bundle is composed at request time from existing workspace, tenant, run, provider, finding, report, review, and audit truth.
|
||||
- Proportionality / `PROP-001` and `ABSTR-001`: PASS — one narrow builder is justified by exactly two real contexts (tenant and run) that must share one deterministic contract; no registry, resolver lattice, or support framework is planned.
|
||||
- Provider boundary / `PROV-001`: PASS — top-level bundle fields stay provider-neutral, while provider-specific semantics remain in provider-owned translated detail.
|
||||
- Shared path reuse / `XCUT-001`: PASS — the feature reuses `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, and `RedactionIntegrity` instead of creating a second support vocabulary.
|
||||
- RBAC, workspace isolation, tenant isolation / `RBAC-UX`: PASS — tenant dashboard access still requires established workspace + tenant scope, `support_diagnostics.view` is scoped through the canonical tenant capability registry and tenant role map, the run-detail action only applies when `OperationRunPolicy` has already resolved an entitled tenant scope, non-members or non-entitled actors stay `404`, and an entitled actor lacking the new capability receives `403` on the action.
|
||||
- Read/write separation and auditability: PASS — the bundle is read-only, writes no Graph or product state, and logs bundle-open activity via the existing audit recorder with redacted metadata only.
|
||||
- Graph contract path: PASS — no new Graph calls are added; render and action hydration remain DB-only.
|
||||
- OperationRun lifecycle / Ops-UX: PASS — the feature does not create, update, or complete runs; it only reuses existing run explanation and link semantics.
|
||||
- Global search / Filament resource requirements: `N/A` — no new global-searchable Filament resource is introduced.
|
||||
- Panel/provider registration: `N/A` — no panel or provider changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php`.
|
||||
- Test governance / `TEST-GOV-001`: PASS — proof stays in focused unit + feature lanes, with no browser or heavy-governance family growth.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for bundle assembly, stable ordering, redaction, and related-reference shaping; Feature for tenant dashboard action behavior, operation-detail action behavior, audit logging, authorization boundaries, and no-side-effect execution proof
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the feature is a server-driven Filament/Livewire read-only action over existing database truth. Unit tests can prove determinism and redaction logic directly, while Feature tests can prove action registration, `404`/`403` boundaries, rendered canonical links, audit logging, and the absence of provider-backed or run-creating side effects without browser duplication.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing `Workspace`, `Tenant`, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` factories; add only one opt-in support-diagnostic seeding helper local to these tests so browser or full provider fixtures do not become defaults.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; support-diagnostic test helpers must stay local and opt-in.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament plus monitoring-state-page coverage is sufficient; assert the action and the resulting preview content rather than browser-level modal choreography.
|
||||
- **Closing validation and reviewer handoff**: rerun the targeted unit and feature commands after implementation; reviewers should confirm `404` versus `403`, explicit redaction markers, deterministic section ordering, canonical link reuse, absence of raw payload or token leakage, and that opening diagnostics creates no new `OperationRun`, Ops UX side effect, or provider-backed work.
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
||||
- **Review-stop questions**: did the implementation introduce a standalone support page/resource, a persisted pack, a raw export path, or browser-only proof? did it bypass existing link/explanation helpers? did any test silently widen into a heavy family?
|
||||
- **Escalation path**: `reject-or-split` if implementation adds persistence, export, support-desk workflow, or system-plane capability expansion; `document-in-feature` for small shared-helper extensions that remain local to this slice
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: the planned unit and feature tests extend existing fixture families and do not create a new recurring governance or browser cost center.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/241-support-diagnostic-pack/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── support-diagnostics.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/Pages/TenantDashboard.php
|
||||
│ ├── Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
│ ├── Policies/OperationRunPolicy.php
|
||||
│ ├── Services/Audit/WorkspaceAuditLogger.php
|
||||
│ ├── Support/Audit/AuditActionId.php
|
||||
│ ├── Support/Auth/Capabilities.php
|
||||
│ ├── Support/Navigation/RelatedNavigationResolver.php
|
||||
│ ├── Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php
|
||||
│ ├── Support/OperationRunLinks.php
|
||||
│ ├── Support/Providers/ProviderReasonTranslator.php
|
||||
│ ├── Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php
|
||||
│ ├── Support/RedactionIntegrity.php
|
||||
│ └── Support/SupportDiagnostics/
|
||||
│ └── SupportDiagnosticBundleBuilder.php
|
||||
└── tests/
|
||||
├── Feature/SupportDiagnostics/
|
||||
│ ├── TenantSupportDiagnosticActionTest.php
|
||||
│ ├── OperationRunSupportDiagnosticActionTest.php
|
||||
│ ├── SupportDiagnosticAuthorizationTest.php
|
||||
│ └── SupportDiagnosticAuditTest.php
|
||||
└── Unit/Support/SupportDiagnostics/
|
||||
├── SupportDiagnosticBundleBuilderTest.php
|
||||
└── SupportDiagnosticBundleRedactionTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. The feature stays inside existing tenant and monitoring detail pages plus one narrowly scoped support-diagnostics builder; no new resource, panel, route group, or persistence layer is introduced.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this plan. The only new structural element is one bounded derived bundle builder, justified below and kept within current-release truth.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: founder-led support still starts with manual cross-page reconstruction across tenant, provider, run, finding, report, review, and audit truth before the real issue can be understood.
|
||||
- **Existing structure is insufficient because**: current pages explain their own local truth, but there is no support-safe, deterministic composition layer that can gather those existing truths into one reusable bundle without page-local drift.
|
||||
- **Narrowest correct implementation**: one read-only bundle builder plus one read-only action on each of the two existing surfaces, reusing existing helpers, canonical links, and audit logging. No new entity, no export pipeline, no support queue, and no additional notification behavior.
|
||||
- **Ownership cost created**: one array-shaped contract, one capability constant and role mapping entry, one audit action identifier, and focused unit + feature coverage.
|
||||
- **Alternative intentionally rejected**: a persisted `SupportDiagnosticPack` model, a standalone support page/resource, and raw payload export/download were rejected as premature persistence and over-broad workflow expansion; page-local copy helpers were rejected as too narrow and drift-prone.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/research.md`
|
||||
|
||||
Goals:
|
||||
- Confirm the narrowest existing sources of truth for tenant, run, provider, finding, report, review-pack, and audit references.
|
||||
- Resolve the capability and product-candidate open question by keeping support diagnostics on already-authorized tenant/admin surfaces only, with no new System Panel visibility bypass in this slice.
|
||||
- Confirm that redaction, related navigation, and run explanation can stay on existing shared helpers instead of introducing a second support-local layer.
|
||||
- Define deterministic ordering and graceful degradation rules for missing, stale, or inaccessible related records.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/quickstart.md`
|
||||
|
||||
Design focus:
|
||||
- Add one read-only `Open support diagnostics` header action to `TenantDashboard` and `TenantlessOperationRunViewer` only.
|
||||
- Compose a tenant-context bundle from existing tenant truth, current relevant provider connection state, the most relevant recent operation context, open or recent findings, latest stored-report freshness, latest tenant-review or review-pack reference when present, and authorized audit references.
|
||||
- Compose an operation-context bundle from the existing humanized run summary plus related provider, finding, report, review, and audit references without changing `OperationRun` lifecycle semantics.
|
||||
- Keep section order and reference ordering deterministic, with explicit `missing`, `stale`, `redacted`, or `inaccessible` cues instead of silent omission.
|
||||
- Reuse canonical navigation and explanation helpers so every link, label, and run explanation means the same thing elsewhere in the product.
|
||||
- Add one tenant-plane capability in the canonical registry and tenant role map for bundle access, keep system-plane support diagnostics out of scope, only expose the run-detail action when the referenced run resolves to an entitled tenant scope, and record bundle-open activity with redacted metadata only.
|
||||
- Keep preview composition native to Filament read-only actions or infolist-style rendering on existing pages. No standalone support page or export/download action is planned.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
After Phase 1 artifacts are generated, update Copilot context from the plan:
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
- Introduce `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder` that exposes explicit `forTenant(...)` and `forOperationRun(...)` entry points and returns one documented derived bundle shape.
|
||||
- Wire a read-only `Open support diagnostics` action into `App\Filament\Pages\TenantDashboard` and populate the preview from authorized tenant truth only.
|
||||
- Wire the same read-only action into `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, reusing `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunLinks`, and existing related-navigation helpers.
|
||||
- Add the dedicated support-diagnostics capability to the canonical tenant capability registry and tenant role map, then enforce it server-side on both actions after membership/entitlement is established and after the run-detail surface has resolved an entitled tenant scope.
|
||||
- Add an audit action identifier and record bundle-open activity with redacted metadata through the existing audit recorder, without storing excluded payload content.
|
||||
- Add focused unit tests for deterministic ordering and redaction plus focused feature tests for tenant/run action behavior, `404`/`403` boundaries, canonical links, and audit logging.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. The design remains derived, provider-neutral at the shared boundary, anchored on existing shared link and explanation paths, explicit about `404` versus `403`, bounded to two read-only surfaces, and contained to focused unit + feature proof with no browser or support-desk drift.
|
||||
|
||||
## Implementation Close-out
|
||||
|
||||
- Guardrail close-out: PASS. The implementation remained read-only, DB-only at render/action time, and did not create new `OperationRun` records or dispatch provider-backed or queued operation work.
|
||||
- Validation lanes passed: targeted unit coverage for deterministic ordering and redaction plus targeted feature coverage for tenant action, run action, authorization boundaries, and audit/no-side-effect guarantees.
|
||||
- Shared-helper note: no follow-up spec was required. The final slice stayed on existing `OperationRunPolicy`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceRunDiagnosticSummaryBuilder`, `RedactionIntegrity`, and `WorkspaceAuditLogger` seams with one bounded `SupportDiagnosticBundleBuilder` abstraction.
|
||||
46
specs/241-support-diagnostic-pack/quickstart.md
Normal file
46
specs/241-support-diagnostic-pack/quickstart.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Quickstart — Support Diagnostic Pack
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail dependencies installed
|
||||
- Existing workspace, tenant, run, provider, finding, report, review, and audit factories available for tests
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Run targeted tests after implementation:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- Format after implementation: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Manual smoke after implementation
|
||||
|
||||
1. Sign in to `/admin` as a workspace member with tenant entitlement and the new tenant-plane `support_diagnostics.view` capability.
|
||||
2. Open one tenant at `/admin/t/{tenant}` and trigger `Open support diagnostics`.
|
||||
3. Confirm the preview shows a deterministic summary, redaction note, canonical related links, and no raw provider payload or credential detail.
|
||||
4. Open one canonical operation detail at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and trigger the same action.
|
||||
5. Confirm the run summary reuses the existing operation explanation language and that related links still open canonical provider, finding, review, review-pack, and audit surfaces.
|
||||
6. Verify a non-member or non-entitled actor receives `404`, while an entitled actor without the support-diagnostics capability sees the action disabled in UI and receives `403` on direct action execution.
|
||||
7. Verify an audit entry is recorded for bundle-open activity with redacted metadata only.
|
||||
8. Verify opening diagnostics stays DB-only in this slice: no new `OperationRun` is created, no provider-backed work is dispatched, and no queued operation UX side effect appears.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays within native Filament page actions and read-only preview composition.
|
||||
- No panel provider changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php`.
|
||||
- No global-search behavior changes are involved because this slice does not add a new Filament resource.
|
||||
- No destructive actions are introduced; `Open support diagnostics` remains a read-only action.
|
||||
- The new `support_diagnostics.view` gate is tenant-role scoped on tenant-admin surfaces; workspace-owned and system-plane runs remain out of scope for this first slice.
|
||||
|
||||
## Implementation Close-out
|
||||
|
||||
- Guardrail result: PASS
|
||||
- Latest targeted validation passed:
|
||||
- `tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`
|
||||
- `tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- Shared-helper note: no follow-up spec is required for this slice; the implementation stayed on existing `OperationRunPolicy`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceRunDiagnosticSummaryBuilder`, `RedactionIntegrity`, and `WorkspaceAuditLogger` paths.
|
||||
140
specs/241-support-diagnostic-pack/research.md
Normal file
140
specs/241-support-diagnostic-pack/research.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Research — Support Diagnostic Pack
|
||||
|
||||
**Date**: 2026-04-25
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
This document captures design decisions and supporting rationale for the first support-diagnostics slice. All decisions are grounded in current repository truth and the TenantPilot Constitution.
|
||||
|
||||
## Decision 1 — The support diagnostic bundle stays derived and read-only
|
||||
|
||||
**Decision**: Keep the first slice as a derived bundle assembled at request time from existing records. Do not introduce a persisted `SupportDiagnosticPack` model, table, queue, or export artifact.
|
||||
|
||||
**Rationale**:
|
||||
- The operator problem is fast support-safe context gathering, not a new long-lived domain object.
|
||||
- Constitution `PERSIST-001` and `PROP-001` require derive-before-persist when current-release truth does not need an independent lifecycle.
|
||||
- The bundle needs deterministic structure and auditability, but neither requires a new stored entity in this slice.
|
||||
|
||||
**Evidence**:
|
||||
- Existing canonical truth already exists on `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog`.
|
||||
- The spec explicitly forbids a persisted support-pack entity and raw export pipeline.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Persist a generated support pack with its own lifecycle.
|
||||
- Rejected: introduces new truth, retention concerns, and export expectations the first slice does not need.
|
||||
- Add page-local copy/export helpers only.
|
||||
- Rejected: too narrow and drift-prone, and fails the cross-surface reuse goal.
|
||||
|
||||
## Decision 2 — Reuse the two existing entry surfaces instead of creating a new support page
|
||||
|
||||
**Decision**: Add read-only `Open support diagnostics` actions to `TenantDashboard` and `TenantlessOperationRunViewer` only. Do not create a standalone Filament resource or route.
|
||||
|
||||
**Rationale**:
|
||||
- The user already reaches support context from an existing tenant or run workflow.
|
||||
- Constitution `DECIDE-001`, `UI-FIL-001`, and the spec’s surface contract require support diagnostics to stay secondary or tertiary, not become a new queue or product area.
|
||||
- Existing surfaces already establish the correct workspace/tenant/run authorization context.
|
||||
|
||||
**Evidence**:
|
||||
- Tenant entry surface: `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||
- Canonical run detail surface: `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- The run detail page already owns scope, back navigation, refresh, and grouped related links.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create `/admin/support-diagnostics/...` pages.
|
||||
- Rejected: wider surface area, new navigation semantics, and unnecessary support-product expansion.
|
||||
|
||||
## Decision 3 — Add one tenant/admin-plane support capability, but do not widen tenant visibility
|
||||
|
||||
**Decision**: Introduce one tenant/admin-plane capability, `support_diagnostics.view`, in the canonical tenant capability registry and role mapping. Keep support diagnostics available only on already-authorized tenant and run surfaces. Do not add any System Panel support-diagnostics capability in this slice.
|
||||
|
||||
**Rationale**:
|
||||
- The spec requires a dedicated support-diagnostics capability and strict `404` vs `403` boundaries.
|
||||
- The roadmap’s open question about support diagnostics revealing tenant metadata without tenant-directory access is resolved narrowly here: this slice does not create a new metadata visibility path. It only augments already-authorized admin-plane surfaces.
|
||||
- That keeps current tenant/workspace isolation intact and defers platform-plane least-privilege splitting to the separate system-panel RBAC work.
|
||||
|
||||
**Evidence**:
|
||||
- Current tenant/admin capability registry: `apps/platform/app/Support/Auth/Capabilities.php`
|
||||
- Current run authorization semantics: `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- Product candidate open question on tenant metadata visibility: `docs/product/spec-candidates.md`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse an existing broad capability such as `tenant.view` or `audit.view`.
|
||||
- Rejected: too coarse for a support-safe bundle that aggregates multiple record types.
|
||||
- Add a platform/system-plane support capability now.
|
||||
- Rejected: outside the current admin-plane slice and would broaden scope into least-privilege platform RBAC.
|
||||
|
||||
## Decision 4 — Existing shared helpers remain authoritative for labels, links, and run explanation
|
||||
|
||||
**Decision**: The bundle builder must reuse `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, and `RedactionIntegrity` instead of creating support-local link builders or explanation text.
|
||||
|
||||
**Rationale**:
|
||||
- Constitution `XCUT-001` requires reuse of the existing shared path for cross-cutting interaction classes.
|
||||
- The support bundle should not create a second vocabulary for operation wording, provider readiness, or related-record labels.
|
||||
- Reuse reduces drift and makes future AI-safe consumers rely on the same canonical operator phrasing.
|
||||
|
||||
**Evidence**:
|
||||
- Canonical run link helpers: `apps/platform/app/Support/OperationRunLinks.php`
|
||||
- Humanized run diagnostic summaries: `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- Provider reason translation: `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`
|
||||
- Related-record navigation and audit target linking: `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
|
||||
- Existing redaction note wording: `apps/platform/app/Support/RedactionIntegrity.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build support-specific strings and URLs inside each page action.
|
||||
- Rejected: duplicated semantics, harder review, and immediate drift risk.
|
||||
|
||||
## Decision 5 — Deterministic ordering is part of the bundle contract, not a UI afterthought
|
||||
|
||||
**Decision**: Fix one section order for every bundle and sort references by stable business signals first, then stable record identity. Prefer run-bound references when the current run explicitly identifies them; otherwise fall back to the current tenant’s latest authorized canonical records.
|
||||
|
||||
**Rationale**:
|
||||
- The spec requires that the same authorized input and unchanged source truth produce the same section order, reference order, and redaction output.
|
||||
- Incidental query order would make later AI consumption and regression testing unreliable.
|
||||
- This decision can stay local to the bundle builder and documented contract without adding a new enum or persistence layer.
|
||||
|
||||
**Expected ordering**:
|
||||
- Section order: overview, provider connection, operation context, findings, stored reports, tenant review, review pack, audit history.
|
||||
- Reference ordering: prefer run-bound reference first; otherwise order by the relevant lifecycle timestamp (`recorded_at`, `generated_at`, `last_seen_at`, `completed_at`, `id`) with deterministic tie-breakers.
|
||||
|
||||
**Evidence**:
|
||||
- `AuditLog` already defines a canonical latest-first ordering via `scopeLatestFirst()`.
|
||||
- `ReviewPack`, `TenantReview`, and `OperationRun` expose stable lifecycle timestamps and latest-first retrieval patterns.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Let each section use its default Eloquent query order.
|
||||
- Rejected: not deterministic enough for the contract promised by the spec.
|
||||
|
||||
## Decision 6 — Audit bundle-open activity through the existing recorder with redacted metadata only
|
||||
|
||||
**Decision**: Record bundle-open activity through `WorkspaceAuditLogger` with a new audit action identifier. Include actor, workspace, tenant when present, primary context type/id, redaction mode, and section/reference counts. Exclude raw provider payloads, secrets, tokens, and unrestricted log excerpts.
|
||||
|
||||
**Rationale**:
|
||||
- The feature is read-only, but support-diagnostics access is still security- and audit-relevant.
|
||||
- `WorkspaceAuditLogger` already supports workspace-scoped and tenant-scoped audit entries without introducing a support-specific persistence path.
|
||||
- Redacted metadata is sufficient for evidence and review.
|
||||
|
||||
**Evidence**:
|
||||
- Workspace-scoped audit writer: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
- Canonical audit action IDs: `apps/platform/app/Support/Audit/AuditActionId.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Skip auditing because the action is read-only.
|
||||
- Rejected: contradicts the spec’s auditability requirement.
|
||||
- Persist the full generated bundle in the audit log.
|
||||
- Rejected: leaks excluded data and violates the derive-before-persist goal.
|
||||
|
||||
## Decision 7 — Proof stays in Unit + Feature lanes only
|
||||
|
||||
**Decision**: Keep proof in focused unit and feature suites. Do not introduce browser coverage, heavy-governance flows, or new lane families for this slice.
|
||||
|
||||
**Rationale**:
|
||||
- The business truth is deterministic bundle composition plus server-side authorization, redaction, canonical link reuse, and audit logging.
|
||||
- Browser tests would mostly duplicate modal/preview rendering and slow down the narrow support slice.
|
||||
- Constitution `TEST-GOV-001` requires the narrowest proving lane mix.
|
||||
|
||||
**Evidence**:
|
||||
- Existing feature coverage already exercises tenant dashboard, canonical run detail, monitoring navigation, audit visibility, and operation-link contracts.
|
||||
- Existing unit coverage already protects shared helpers such as run summaries and navigation resolvers.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add browser smoke tests for modal interaction.
|
||||
- Rejected: browser proof is not necessary to establish the core business truth of this first slice.
|
||||
297
specs/241-support-diagnostic-pack/spec.md
Normal file
297
specs/241-support-diagnostic-pack/spec.md
Normal file
@ -0,0 +1,297 @@
|
||||
# Feature Specification: Support Diagnostic Pack
|
||||
|
||||
**Feature Branch**: `241-support-diagnostic-pack`
|
||||
**Created**: 2026-04-25
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Support Diagnostic Pack"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Support and troubleshooting work still requires manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, StoredReport, TenantReview, review artifacts, and audit history before the real issue can be addressed.
|
||||
- **Today's failure**: A founder or support-capable operator must reconstruct the case by hand, which slows first response, increases the chance of oversharing sensitive provider context, and leaves later AI-assisted support without a safe canonical input layer.
|
||||
- **User-visible improvement**: An entitled operator can open one deterministic, redacted diagnostic bundle for a tenant or one operation run and immediately see the current issue, freshness, and the right canonical records to inspect next.
|
||||
- **Smallest enterprise-capable version**: A read-only, derived diagnostic bundle contract for tenant context and OperationRun context that references existing canonical records, applies first-class redaction and access checks, and writes audit entries for bundle generation without creating a new persisted support-pack entity.
|
||||
- **Explicit non-goals**: No external ticketing or helpdesk integration, no support-desk product, no unrestricted raw payload export or download, no broad log export pipeline, no AI chatbot or autonomous AI support behavior, and no application implementation in this preparation artifact.
|
||||
- **Permanent complexity imported**: One derived support-diagnostic bundle contract, one deterministic ordering and redaction policy, two bounded action entry points, and focused unit and feature coverage for authorization, redaction, and reference continuity.
|
||||
- **Why now**: Self-Service Tenant Onboarding & Connection Readiness already exists as Spec 240, so the next supportability bottleneck is founder-led troubleshooting. This slice is the next recommended candidate, directly reduces manual support work, and reuses strong existing foundations that are already in the repo.
|
||||
- **Why not local**: Page-local exports or copy helpers on one tenant or run page would duplicate truth, drift labels and redaction behavior, and still fail to provide one reusable support-safe contract across support workflows.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Two red flags apply: it sounds like a foundation/support layer, and it touches more than one surface. Defense: the first slice is tightly limited to tenant context and OperationRun context, introduces no persistence, composes existing record truth instead of inventing a helpdesk framework, and explicitly defers broader support product work.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}` as the first tenant-context entry point for a tenant-scoped support diagnostic bundle
|
||||
- `/admin/operations/{run}` as the first OperationRun-context entry point for a run-scoped support diagnostic bundle
|
||||
- Existing related destinations reached from the bundle, including tenant findings, provider connection detail, tenant review detail, review-pack detail when present, and audit-log event detail
|
||||
- **Data Ownership**: No new `support_diagnostic_packs` truth is introduced. The bundle is derived from existing canonical records. Source truth remains on workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack` when present, and `AuditLog` references tied to the same authorized scope.
|
||||
- **RBAC**: Workspace membership is required, and both entry surfaces stay in the tenant-admin `/admin` plane. Tenant entitlement is required before any tenant-owned record is resolved. A dedicated tenant-role capability `support_diagnostics.view` in the canonical capability registry and tenant role map gates bundle generation and bundle viewing on both entry surfaces. The tenantless operation viewer only offers the action when the referenced run resolves to an entitled tenant scope in this slice; workspace-owned or system-plane runs remain out of scope. Existing per-record permissions still govern whether linked canonical records may be opened after the bundle is shown.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- Not applicable as a primary scope because the first slice adds support-diagnostic actions to existing tenant and operation-detail surfaces rather than introducing a new canonical collection page.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: header actions, diagnostic summaries, related-record links, evidence and report references, audit drill-throughs
|
||||
- **Systems touched**: canonical operation related links, governance run explanation summaries, provider reason translation, existing resource detail routes, and audit-log detail resolution
|
||||
- **Existing pattern(s) to extend**: existing operation related-link behavior, existing humanized operation explanation behavior, existing tenant-safe related-record routing, and existing audit-log navigation
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, and existing tenant-safe record viewers for findings, provider connections, tenant reviews, review packs, and audit-log events
|
||||
- **Why the existing shared path is sufficient or insufficient**: The existing paths are sufficient for labels, links, and run explanation language, but they do not yet assemble one support-safe cross-record bundle with deterministic ordering and redaction.
|
||||
- **Allowed deviation and why**: One new derived bundle assembler is allowed, but it may only compose existing truth and existing shared helpers. Parallel page-local link builders, raw payload embedding, or a second support-summary dialect are not allowed.
|
||||
- **Consistency impact**: Operation labels, related-record labels, provider explanation wording, redaction messaging, and audit-reference semantics must stay aligned with existing shared helpers so support workflows do not develop a local vocabulary.
|
||||
- **Review focus**: Reviewers must verify that the bundle reuses shared link and explanation paths, does not duplicate record truth, and never embeds raw provider payloads or bundle-local provider-specific semantics by default.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for deep-link and explanation reuse only
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` for tenant-safe operation routing and existing governance-run summary builders for run explanation language
|
||||
- **Delegated start/completion UX behaviors**: tenant-safe `Open operation` and related-record link semantics are reused; queued toast, browser event, dedupe-or-blocked messaging, artifact-link creation beyond existing links, and queued DB notifications are `N/A` in this slice
|
||||
- **Local surface-owned behavior that remains**: a read-only `Open support diagnostics` action from the tenant dashboard or operation detail surface
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed
|
||||
- **Seams affected**: provider connection descriptors, provider reason translation, translated provider error excerpts, and redaction boundaries around provider-owned diagnostics
|
||||
- **Neutral platform terms preserved or introduced**: support diagnostic bundle, tenant context, operation context, provider connection, related record, support summary, redaction reason, audit reference
|
||||
- **Provider-specific semantics retained and why**: Microsoft-specific permission, consent, or provider failure reasons may appear only as translated excerpts inside provider-owned sub-sections when they are genuinely needed to explain the current issue.
|
||||
- **Why this does not deepen provider coupling accidentally**: The bundle contract remains provider-neutral and references provider specifics only through existing provider-owned descriptors and reason translators. It does not introduce provider-shaped primary fields as platform-core truth.
|
||||
- **Follow-up path**: none in this slice; later support or AI features can build on the same neutral bundle contract
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | yes | Native Filament + shared primitives | header actions, diagnostic summaries, related links | page, action, preview | yes | Existing tenant dashboard action-surface exemption remains; this slice adds one bounded support action rather than reclassifying the dashboard as a queue |
|
||||
| Monitoring operation detail support diagnostic action | yes | Native Filament + shared diagnostics primitives | operation detail, related links, audit drill-through | detail, action, preview | no | Extends the existing diagnostic surface instead of creating a second operation-detail dialect |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | Secondary Context Surface | An operator decides they need help or escalation for one tenant and wants a support-safe case summary before leaving the tenant workflow | Tenant identity, bundle freshness, provider connection state, latest relevant run or finding pressure, and a visible redaction boundary | Redacted section detail plus canonical links to runs, findings, reviews, reports, and audit events | Not primary because support is follow-up to tenant work, not the tenant’s daily decision queue | Follows tenant troubleshooting and escalation flow | Removes manual search across operations, provider, findings, reviews, and audit pages |
|
||||
| Monitoring operation detail support diagnostic action | Tertiary Evidence / Diagnostics Surface | An operator is already inspecting one run and needs a support-safe summary they can act on or escalate | Run outcome, dominant issue, related record references, freshness, and a visible redaction boundary | Redacted section detail plus canonical links to provider, findings, review artifacts, and audit history | Not primary because operation detail is already a drill-in evidence surface | Follows monitoring drill-in workflow | Removes cross-page reconstruction from a single failed or degraded run |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | Dashboard / Overview / Actions | Tenant troubleshooting support entry point | Open the redacted tenant diagnostic bundle | Explicit header action opens a read-only support-diagnostic preview | forbidden | Existing tenant links remain secondary; bundle links live inside the preview | none | /admin/t/{tenant} | /admin/t/{tenant} | Active workspace, active tenant, bundle freshness, redaction notice | Support diagnostics / Support diagnostic bundle | Current issue summary, top related records, and redaction boundary | dashboard_exception - tenant dashboard already has a documented action-surface exemption; this action remains bounded and read-only |
|
||||
| Monitoring operation detail support diagnostic action | Record / Detail / Actions | Canonical diagnostic detail support entry point | Open the redacted run diagnostic bundle | Existing operation detail page plus one explicit support-diagnostic action | forbidden | Existing related links remain secondary and continue to use the canonical operation surface | none | /admin/operations | /admin/operations/{run} | Workspace context, active tenant context when present, operation identifier, redaction notice | Support diagnostics / Support diagnostic bundle | Dominant issue, related records, freshness, and redaction boundary | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | Workspace manager or support-capable tenant operator | Decide whether the tenant case can be escalated or troubleshot with one support-safe bundle | Dashboard action + read-only preview | Do I have enough support-safe context for this tenant without opening raw provider diagnostics? | Tenant identity, workspace context, provider connection state, latest relevant run summary, active finding pressure, latest report or review references, and redaction notice | Full related records remain on their native pages; raw payloads and unrestricted logs stay excluded | connection health, run freshness, finding pressure, report freshness, review availability | None | Open support diagnostics, open canonical related record links | none |
|
||||
| Monitoring operation detail support diagnostic action | Workspace manager or support-capable operator | Decide whether one operation case has enough support-safe context for follow-up or escalation | Detail action + read-only preview | What is the current operation issue, what related records matter, and what is safe to share or reuse for support? | Humanized run summary, related provider and tenant references, current finding or review references, audit references, and redaction notice | Full diagnostics remain on canonical run, finding, provider, review, or audit pages; raw payloads stay excluded | execution outcome, provider connection state, artifact freshness, related finding pressure | None | Open support diagnostics, open canonical related record links | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Support work starts too late because someone must first gather the relevant tenant, provider, run, finding, report, review, and audit context by hand.
|
||||
- **Existing structure is insufficient because**: Existing pages explain local truth, but no current path assembles one support-safe, deterministic bundle across tenant and OperationRun context while enforcing redaction and deny-as-not-found behavior first.
|
||||
- **Narrowest correct implementation**: Add one derived bundle contract for tenant and OperationRun contexts only, with references to existing canonical records, deterministic ordering, redaction, and audit logging. Do not persist the bundle, build a helpdesk product, or add a generic export framework.
|
||||
- **Ownership cost**: Maintain one derived bundle schema, one deterministic redaction policy, one audit event family for bundle usage, and focused unit and feature coverage for authorization and reference continuity.
|
||||
- **Alternative intentionally rejected**: A persisted `SupportDiagnosticPack` entity and broad raw-export pipeline were rejected as too heavy, and page-local copy/export helpers were rejected as too narrow and drift-prone.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: Unit coverage proves deterministic section ordering, redaction behavior, and canonical reference shaping. Feature coverage proves tenant and OperationRun entry points, deny-as-not-found isolation, capability enforcement, and canonical related-link continuity without introducing browser or heavy-governance breadth.
|
||||
- **New or expanded test families**: A focused support-diagnostics unit family plus targeted feature coverage for tenant-context and operation-context actions and authorization boundaries
|
||||
- **Fixture / helper cost impact**: Moderate. Tests can reuse existing workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` fixtures, but must add explicit redaction and inaccessible-reference cases.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament + monitoring-state-page
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for redaction markers, stable ordering, 404 versus 403 semantics, canonical related-link reuse, and the absence of new run-creating or provider-backed side effects when diagnostics are opened.
|
||||
- **Reviewer handoff**: Reviewers must confirm that no raw provider payloads or secrets appear in the bundle, that unauthorized cross-workspace or cross-tenant access is treated as not found, that entitled-but-uncapable users receive authorization failure, that canonical links and labels stay aligned with existing support helpers, and that opening diagnostics creates no new `OperationRun` or provider-backed side effect.
|
||||
- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit and feature coverage only; no new browser or heavy-governance baseline is expected.
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Open a tenant support-safe bundle (Priority: P1)
|
||||
|
||||
As a workspace manager or support-capable operator, I want one tenant-scoped diagnostic bundle so I can start support without manually gathering records across multiple pages.
|
||||
|
||||
**Why this priority**: This is the first support workflow compression win. If tenant context still has to be rebuilt by hand, the feature has not reduced founder-led support work.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a tenant-scoped support diagnostic bundle from the tenant dashboard with seeded provider, finding, report, review, and audit records and verifying that the bundle is redacted, deterministic, and linked to canonical records only.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled operator opens support diagnostics for a tenant with an unhealthy provider connection, recent failed run, open findings, and a recent tenant review, **When** the bundle renders, **Then** it shows one redacted tenant summary with canonical references to the provider connection, most relevant operation run, related findings, latest stored reports, tenant review or review pack when present, and relevant audit references.
|
||||
2. **Given** the current user is not a member of the workspace or is not entitled to the tenant, **When** they try to open the tenant bundle, **Then** the system responds as not found and does not reveal whether the tenant has provider issues, findings, or support history.
|
||||
3. **Given** the current user is entitled to the tenant but lacks the support-diagnostics capability, **When** they try to open the tenant bundle, **Then** the system denies the action as an authorization failure without revealing additional diagnostic detail.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Open a run-centered support-safe bundle (Priority: P1)
|
||||
|
||||
As a support-capable operator already inspecting a run, I want a run-centered diagnostic bundle that uses the same operation explanation language as Monitoring so I can diagnose or escalate one case quickly.
|
||||
|
||||
**Why this priority**: A large share of support work begins with one suspicious run. If run context still requires cross-page reconstruction, the first-response workflow remains too slow.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening an operation-scoped support diagnostic bundle from the canonical operation detail surface and verifying that the bundle reuses existing humanized run summaries, canonical related links, and redaction rules.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled operator opens support diagnostics for a failed or degraded operation run, **When** the bundle renders, **Then** it reuses the existing humanized operation explanation, includes the related provider connection, related finding, related stored report, related tenant review or review pack when present, and relevant audit references, and keeps those references canonical rather than duplicating record truth.
|
||||
2. **Given** the operation context contains sensitive provider response data or raw payload excerpts, **When** the bundle renders, **Then** those values are excluded by default and replaced with explicit redaction markers or translated high-level reasons.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Rely on deterministic, redacted support summaries (Priority: P2)
|
||||
|
||||
As a product owner preparing later AI-assisted support, I want support diagnostic bundles to be deterministic and machine-readable so later support tooling can reuse them without widening access or depending on ad-hoc summaries.
|
||||
|
||||
**Why this priority**: Deterministic structure is what makes the first slice reusable later without adding a second translation layer or unsafe copy-and-paste support flow.
|
||||
|
||||
**Independent Test**: Can be fully tested by generating the same authorized bundle repeatedly against unchanged source truth and verifying stable section order, stable reference order, and stable redaction behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the same authorized tenant or run input and unchanged source truth, **When** the bundle is generated multiple times, **Then** the same sections, reference order, and redaction outcomes appear in the same order every time.
|
||||
2. **Given** a related record becomes missing or inaccessible after the first generation, **When** the bundle is generated again, **Then** the summary marks that record as missing or inaccessible without leaking details from the inaccessible record.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant may have no provider connection, no recent operation run, or no current tenant review; the bundle must still render a truthful support summary with explicit `missing`, `not yet observed`, or `not available` states.
|
||||
- A run may reference a stored report that has no dedicated viewer surface. The bundle must reference the canonical record identity and freshness without inventing a new report page.
|
||||
- A run may reference a review pack or tenant review that has expired, been deleted, or is no longer accessible; the bundle must degrade gracefully and preserve deny-as-not-found boundaries.
|
||||
- Provider error detail may exist only in raw payload context. The bundle must prefer translated high-level reason semantics and a redaction marker instead of echoing raw payload content.
|
||||
- Workspace-level audit events may exist without a tenant-bound detail record. The bundle must include only audit references that are valid for the authorized scope and primary context.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new destructive workflow, and no new queued support-processing pipeline. Bundle generation is a read-only supportability action. If implementation writes no `OperationRun`, it must still write `AuditLog` entries for bundle generation and any explicit copy or share action using redacted metadata only.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded derived bundle contract because current tenant and operation pages still force manual reconstruction for support. No new persistence, status family, or support-desk domain entity is added. The design stays aligned with derive-before-persist and explicit-before-generic by composing existing records instead of creating a generalized support framework.
|
||||
|
||||
**Constitution alignment (XCUT-001):** This feature is cross-cutting across operation links, explanation summaries, provider reason translation, evidence or review viewers, and audit drill-through. It must extend the existing shared paths instead of creating page-local support-link or support-summary dialects.
|
||||
|
||||
**Constitution alignment (PROV-001):** The support diagnostic bundle contract stays provider-neutral. Provider-specific content remains contextual inside provider-owned translated sections and must not become new platform-core fields in the bundle.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature coverage. No heavy-governance or browser lane is justified for the first slice. Fixture cost must remain explicit and limited to real support contexts using existing models.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle rules, `summary_counts`, and notification rules remain unchanged. The feature reuses `OperationRun` summaries and links only; it does not add a new operation type or change run-state ownership.
|
||||
|
||||
**Constitution alignment (OPS-UX-START-001):** The feature reuses `OperationRunLinks` for tenant-safe URL resolution and existing operation labels. It does not add queued toasts, dedupe messaging, lifecycle notifications, or run-enqueued browser events.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is the tenant-admin `/admin` plane, including tenant-context routes and the canonical tenantless operation detail viewer. Non-members or non-entitled users must receive 404. The new `support_diagnostics.view` gate is tenant-role scoped through the canonical tenant capability registry and role map, and the run-context action is only in scope when the referenced run resolves to an entitled tenant. Entitled members lacking that capability must receive 403. Server-side authorization must run before any bundle section resolves a tenant-owned record. Linked canonical destinations continue to enforce their own authorization rules.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** No new badge family is required. Existing status and reason semantics remain authoritative.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature must use native Filament page or header actions and read-only summary sections or infolist-style presentation. Local replacement markup for status semantics or redaction language is intentionally avoided.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the support diagnostic bundle. Primary operator-facing verbs remain `Open support diagnostics`, `Open operation`, `Open finding`, `Open provider connection`, and related canonical record labels. Implementation-first terms such as payload blob, Graph response, or raw JSON must stay out of primary labels.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The affected surfaces remain secondary or tertiary support and diagnostics contexts. The feature must not create a new decision queue or a new support-desk surface.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Each affected surface keeps exactly one primary inspect or open model. The support-diagnostic action is read-only, secondary to the existing tenant or operation surface, and must not compete with mutation or add redundant `View` actions.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** The support-diagnostic action is a contextual read-only action. It must stay separated from mutation and dangerous actions and must not turn into a mixed catch-all support menu.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first: summary, freshness, related record references, and redaction state first; raw diagnostics remain on the original canonical pages.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from one existing page is insufficient because support context spans multiple canonical records. The derived bundle replaces manual reconstruction, not existing truth. Tests must prove business outcomes: safe first-response context, safe redaction, and safe authorization boundaries.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each affected surface adds one explicit read-only support-diagnostic action, keeps one primary inspect model, adds no redundant `View` action, and adds no destructive placement changes. The tenant dashboard keeps its existing exemption for broader dashboard retrofit work.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Any preview or detail presentation for the bundle must use summary-first sections, explicit redaction messaging, and one clear path back to the canonical records. The feature does not add create or edit screens.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-241-001 Context coverage**: The system MUST allow an entitled operator to generate a support diagnostic bundle for at least two first-slice contexts: one tenant context and one specific `OperationRun` context.
|
||||
- **FR-241-002 Canonical truth reuse**: The bundle MUST reference existing canonical records for workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack` when present, and `AuditLog` references, instead of duplicating their full truth into a new persisted support record.
|
||||
- **FR-241-003 Tenant summary shape**: A tenant-context bundle MUST include a deterministic redacted summary covering tenant identity, workspace scope, provider connection health, most relevant recent operation context, relevant active findings, latest stored-report freshness, tenant-review or review-pack references when present, and audit references relevant to the same scope.
|
||||
- **FR-241-004 Operation summary shape**: An `OperationRun`-context bundle MUST reuse existing humanized operation summary language, include related provider and artifact references when present, include relevant findings or review artifacts when present, and include audit references tied to the same run or immediate follow-up.
|
||||
- **FR-241-005 Deterministic ordering**: For the same authorized input and unchanged source truth, the bundle MUST emit the same section order, same reference order, and same redaction outcomes.
|
||||
- **FR-241-006 Redaction first**: Sensitive raw provider payloads, secrets, tokens, full response bodies, and unrestricted log excerpts MUST be excluded by default. When exclusion happens, the bundle MUST show explicit redaction markers or translated high-level reasons instead of silent omission.
|
||||
- **FR-241-007 Access checks first**: Workspace membership, tenant entitlement, and support-diagnostics capability checks MUST run before any tenant-owned bundle section is resolved.
|
||||
- **FR-241-008 404 versus 403 boundaries**: Non-members and non-entitled users MUST receive deny-as-not-found behavior. Entitled users lacking the support-diagnostics capability MUST receive authorization failure. Inaccessible related records inside an otherwise allowed bundle MUST degrade safely without revealing protected record details.
|
||||
- **FR-241-009 Canonical link continuity**: Bundle links and record labels MUST reuse existing canonical navigation and explanation helpers so the bundle uses the same record names and destination meaning as the rest of the product.
|
||||
- **FR-241-010 Freshness and completeness cues**: The bundle MUST show freshness, missing-data, or stale-data cues for included references so support workflows can distinguish fresh context from incomplete or absent context.
|
||||
- **FR-241-011 Auditability**: Bundle generation and any explicit bundle copy or share action in scope MUST write audit entries that record actor, scope, primary context, and redaction mode without storing excluded raw payload content.
|
||||
- **FR-241-012 No new support-pack persistence**: The first slice MUST NOT create a persisted `SupportDiagnosticPack` entity, a support queue, or a broad export pipeline.
|
||||
- **FR-241-013 Later AI readiness**: The bundle contract MUST remain machine-readable and support-safe so later AI-assisted support can consume the same contract without requiring broader access.
|
||||
- **FR-241-014 Graceful missing-reference handling**: If a referenced canonical record is missing, expired, or inaccessible, the bundle MUST identify that state explicitly and continue rendering the remaining authorized context.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostics | `App\Filament\Pages\TenantDashboard` | `Open support diagnostics` (read-only, capability-gated) | n/a | none | none | none added | `Open support diagnostics` | n/a | yes | Existing tenant dashboard exemption remains; this slice adds one bounded support action only |
|
||||
| Monitoring operation detail support diagnostics | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | `Open support diagnostics` (read-only, capability-gated) | Existing operation detail remains the primary inspect model | none | none | n/a | `Open support diagnostics` | n/a | yes | No new destructive action, no redundant `View` action, no action-group catch-all |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Support Diagnostic Bundle**: A derived, read-only support-safe envelope for one tenant context or one operation context.
|
||||
- **Diagnostic Reference Set**: The typed set of canonical record references included in the bundle, such as tenant, operation, provider connection, finding, stored report, tenant review, review pack, and audit reference.
|
||||
- **Redaction Marker**: An explicit indicator that a sensitive value or raw payload section was intentionally excluded and why.
|
||||
- **Diagnostic Context Slice**: The bounded entry context for one bundle generation request, either tenant context or `OperationRun` context.
|
||||
|
||||
## Assumptions & Dependencies
|
||||
|
||||
- Spec 240 Self-Service Tenant Onboarding & Connection Readiness already exists on its own feature branch and remains the immediately preceding supportability milestone.
|
||||
- Existing `OperationRun`, `Finding`, `ProviderConnection`, `StoredReport`, `TenantReview`, `ReviewPack`, `AuditLog`, and operator explanation foundations are mature enough to support a derived bundle without introducing new persistence.
|
||||
- Existing operation explanation and related-link helpers remain the authoritative path for run wording and canonical navigation continuity.
|
||||
- `StoredReport` currently acts as canonical evidence truth without a dedicated general-purpose report viewer; the first slice therefore references report identity and freshness instead of inventing a new report surface.
|
||||
- Product Usage & Adoption Telemetry and Operational Controls & Feature Flags remain deferred because they either depend on onboarding and readiness behavior landing first or require new greenfield control and telemetry infrastructure that this slice intentionally avoids.
|
||||
|
||||
## Risks
|
||||
|
||||
- Over-including provider diagnostics would create unnecessary data-exposure risk and undermine tenant or workspace isolation.
|
||||
- Under-including context would push support users back to manual reconstruction and fail the workflow-compression goal.
|
||||
- A page-local implementation on only one surface could drift from shared operation or provider language and create inconsistent support summaries.
|
||||
- Deterministic bundle ordering must not accidentally depend on incidental query order or uncontrolled related-record selection.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None for the first slice. Follow-up questions about external ticket attachment, persistent support requests, or AI-assisted support behavior are explicitly deferred.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Add an external ticketing or helpdesk integration.
|
||||
- Create a support-desk product or support queue inside TenantPilot.
|
||||
- Allow unrestricted raw provider payload export or download.
|
||||
- Build a broad log export pipeline.
|
||||
- Add an AI chatbot, autonomous AI support behavior, or agentic triage flow.
|
||||
- Introduce a persisted support-pack entity in the first slice.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-241-001**: In acceptance review, an entitled operator can open a tenant or operation support diagnostic bundle and identify the current issue, the authorized scope, and the next canonical records to inspect within 30 seconds without manually searching across more than one additional product surface.
|
||||
- **SC-241-002**: In validation scenarios, 100% of covered tenant and operation bundle cases exclude sensitive raw provider payloads by default while still surfacing the relevant related-record references and freshness cues.
|
||||
- **SC-241-003**: In validation scenarios, 100% of unauthorized cross-workspace or cross-tenant bundle attempts return not found, and 100% of entitled-but-uncapable attempts return authorization failure.
|
||||
- **SC-241-004**: In deterministic regression coverage, repeated generation of the same unchanged authorized bundle produces the same section order, same reference order, and same redaction markers.
|
||||
194
specs/241-support-diagnostic-pack/tasks.md
Normal file
194
specs/241-support-diagnostic-pack/tasks.md
Normal file
@ -0,0 +1,194 @@
|
||||
---
|
||||
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: Support Diagnostic Pack
|
||||
|
||||
**Input**: Design documents from `/specs/241-support-diagnostic-pack/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the targeted `fast-feedback` and `confidence` unit + feature suites listed in `specs/241-support-diagnostic-pack/quickstart.md`.
|
||||
|
||||
## Scope Lock
|
||||
|
||||
- This task list covers only tenant-context and `OperationRun`-context support diagnostic bundles on the existing admin-plane tenant dashboard and tenantless operation detail viewer.
|
||||
- Deferred by design: external ticketing, standalone support pages or resources, raw export or download flows, AI runtime behavior, heavy browser coverage, and system-panel support surfaces.
|
||||
|
||||
## Test Governance Notes
|
||||
|
||||
- Lane assignment: targeted unit + feature proof from `specs/241-support-diagnostic-pack/quickstart.md` is the narrowest sufficient validation for bundle composition, authorization, redaction, canonical-link reuse, and audit logging.
|
||||
- No new browser or heavy-governance family should be introduced; keep any helper or fixture growth local to `SupportDiagnostics` tests and cheap by default.
|
||||
- Surface profile: `standard-native-filament` relief applies to the tenant dashboard action, and the canonical operation detail action must preserve the `monitoring-state-page` contract already documented in the spec and plan.
|
||||
- If implementation leaves a bounded shared-helper or provider-boundary hotspot, record that outcome in `specs/241-support-diagnostic-pack/plan.md` and `specs/241-support-diagnostic-pack/quickstart.md` before merge instead of widening this slice.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Pin the first-slice scope, existing surfaces, and shared helpers before runtime edits begin.
|
||||
|
||||
- [X] T001 Review the first-slice bundle contract, scope lock, and validation commands in `specs/241-support-diagnostic-pack/spec.md`, `specs/241-support-diagnostic-pack/plan.md`, `specs/241-support-diagnostic-pack/research.md`, `specs/241-support-diagnostic-pack/data-model.md`, `specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml`, and `specs/241-support-diagnostic-pack/quickstart.md`
|
||||
- [X] T002 [P] Verify the existing tenant dashboard, canonical tenantless operation viewer, and shared helper seams that this slice must reuse in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, and `apps/platform/app/Support/RedactionIntegrity.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared derived-bundle, capability, and audit seams required by both entry contexts without adding persistence or a standalone support surface.
|
||||
|
||||
**Critical**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T003 Create the derived support-diagnostics bundle shell with explicit `forTenant(...)` and `forOperationRun(...)` entry points and one documented runtime shape in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
- [X] T004 [P] Register `support_diagnostics.view` in the canonical tenant capability registry and tenant role map without adding any system-plane support variant in `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
|
||||
- [X] T005 [P] Add the shared audit action identifier and redacted bundle-open metadata path for both contexts in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
|
||||
**Checkpoint**: Foundation ready - both entry contexts can build on one derived bundle contract, one capability gate, and one audit path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Open A Tenant Support-Safe Bundle (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: An entitled operator can open one tenant-scoped support diagnostic bundle from the tenant dashboard and get deterministic, redacted, canonical context without manual cross-page reconstruction.
|
||||
|
||||
**Independent Test**: Seed an entitled tenant with provider, run, finding, stored-report, review, and audit truth, open support diagnostics from the tenant dashboard, and confirm the preview stays redacted, canonical, and authorization-safe.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T006 [P] [US1] Add tenant-context feature coverage for the redacted overview, provider or run or finding or report or review or audit references, freshness cues, and `404` versus `403` behavior in `apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [US1] Implement tenant-context bundle collection from canonical tenant, provider connection, recent operation, findings, stored reports, tenant review or review pack, and audit truth in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
- [X] T008 [US1] Add the read-only `Open support diagnostics` header action and preview rendering on the tenant dashboard with capability gating after membership and entitlement are established in `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||
- [X] T009 [US1] Keep tenant-context provider excerpts and redaction notes on shared provider-owned helpers instead of page-local wording in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when an entitled operator can open a tenant bundle and identify the current issue plus the next canonical records to inspect without seeing raw payload content.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Open A Run-Centered Support-Safe Bundle (Priority: P1)
|
||||
|
||||
**Goal**: An entitled operator already inspecting one run can open the same support-safe contract from the canonical operation detail surface and keep existing run language and navigation semantics.
|
||||
|
||||
**Independent Test**: Seed a failed or degraded run with related provider, finding, report, review, and audit truth, open support diagnostics from the tenantless operation detail viewer, and confirm the preview reuses humanized run wording plus canonical links only.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T010 [P] [US2] Add operation-context feature coverage for reused humanized run summary, canonical related links, explicit redaction markers, and deny-as-not-found boundaries in `apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T011 [US2] Implement operation-context bundle collection by reusing the current run explanation builder and run-bound related-record truth in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T012 [US2] Add the read-only `Open support diagnostics` header action to the canonical tenantless operation viewer and enforce `support_diagnostics.view` only after existing run access has resolved an entitled tenant scope in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- [X] T013 [US2] Reuse canonical operation and related-record navigation instead of support-local URLs in `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when the run detail surface can open the same support bundle contract without changing `OperationRun` lifecycle truth or introducing a second link vocabulary.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Rely On Deterministic, Redacted Support Summaries (Priority: P2)
|
||||
|
||||
**Goal**: The same authorized tenant or run input always produces the same ordered, machine-readable, redacted support bundle so later tooling can reuse it safely.
|
||||
|
||||
**Independent Test**: Generate the same authorized tenant and run bundles repeatedly against unchanged truth and confirm stable section order, stable reference order, explicit missing or inaccessible placeholders, and redaction markers for sensitive provider detail.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T014 [P] [US3] Add unit coverage for stable section order, stable reference order, and graceful missing or inaccessible degradation in `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`
|
||||
- [X] T015 [P] [US3] Add unit coverage for redaction markers, translated high-level provider reasons, and excluded raw payload fields in `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- [X] T016 [P] [US3] Add shared feature coverage for cross-surface authorization boundaries so non-members or non-entitled actors stay `404` and capability-denied members stay `403` in `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`
|
||||
- [X] T017 [P] [US3] Add feature coverage for bundle-open audit entries with redacted metadata for both tenant and run contexts, and assert that opening diagnostics stays DB-only, performs no outbound HTTP, dispatches no provider-backed work, creates no new `OperationRun`, and emits no queued OperationRun-start side effect, in `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T018 [US3] Finalize deterministic ordering, freshness and completeness cues, explicit missing or inaccessible placeholders, and redaction-marker output in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and `apps/platform/app/Support/RedactionIntegrity.php`
|
||||
- [X] T019 [US3] Wire bundle-open audit recording from both read-only actions without storing excluded payload content in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when bundle output is deterministic, redacted, machine-readable, and auditable across both approved contexts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Align the final contract docs, format touched files, and run the narrow validation set for this slice.
|
||||
|
||||
- [X] T020 [P] Update the support-diagnostics contract and manual validation notes to match the final tenant and run action behavior in `specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml` and `specs/241-support-diagnostic-pack/quickstart.md`
|
||||
- [X] T021 [P] Run Laravel Pint on touched PHP files through Sail before merge using `apps/platform/vendor/bin/sail`
|
||||
- [X] T022 Run the targeted unit and feature validation commands listed in `specs/241-support-diagnostic-pack/quickstart.md` after implementation completes, explicitly using those suites to prove the DB-only / no outbound HTTP / no queued OperationRun-start side-effect contract via `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`, `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`, and `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- [X] T023 Record the final guardrail close-out, lane result, and any bounded `document-in-feature` versus `follow-up-spec` note for shared-helper or provider-boundary adjustments in `specs/241-support-diagnostic-pack/plan.md` and `specs/241-support-diagnostic-pack/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Phase 1 (Setup) starts immediately.
|
||||
- Phase 2 (Foundational) depends on Phase 1 and blocks all user stories.
|
||||
- Phase 3 (US1) depends on Phase 2 and establishes the MVP tenant-context bundle.
|
||||
- Phase 4 (US2) depends on Phase 2 and is safest after US1 in practice because both stories extend `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and the same canonical navigation helpers.
|
||||
- Phase 5 (US3) depends on US1 and US2 because it hardens deterministic output, shared authorization proof, and audit behavior across both approved contexts.
|
||||
- Phase 6 (Polish) depends on every implemented story.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 is the MVP and the first independently shippable increment.
|
||||
- US2 remains independently testable, but it reuses the same builder and helper seams as US1, so merge order should favor US1 first.
|
||||
- US3 depends on both P1 stories because deterministic ordering, redaction, authorization proof, and audit proof must cover the shared bundle contract across both contexts.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and ensure it fails before implementation.
|
||||
- Complete shared builder or helper changes before the final page-action rendering pass when both are required.
|
||||
- Re-run the narrowest affected unit or feature suite after each story checkpoint before moving to the next story.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
### Phase 1
|
||||
|
||||
- T001 and T002 can run in parallel if one person confirms the feature documents while another verifies the shared code seams.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- T004 and T005 can run in parallel after T003 defines the shared bundle shell.
|
||||
|
||||
### User Story 1
|
||||
|
||||
- T006 can start before runtime edits.
|
||||
- T008 and T009 can overlap once T007 establishes the tenant-context bundle payload.
|
||||
|
||||
### User Story 2
|
||||
|
||||
- T010 can start before runtime edits.
|
||||
- T012 and T013 can overlap once T011 establishes the run-context bundle payload.
|
||||
|
||||
### User Story 3
|
||||
|
||||
- T014, T015, T016, and T017 can run in parallel.
|
||||
- T018 and T019 should stay sequential because both finalize the shared builder and action audit flow.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1.
|
||||
2. Complete Phase 2.
|
||||
3. Complete Phase 3 (US1).
|
||||
4. Re-run the targeted tenant-context suite and stop for review.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver US1 to compress tenant-context support work first.
|
||||
2. Add US2 to bring the same contract to the canonical run-detail surface.
|
||||
3. Add US3 to harden deterministic output, redaction, authorization, and audit proof across both contexts.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Finish Phase 2 together before splitting work.
|
||||
2. Parallelize test authoring inside each story.
|
||||
3. Sequence merges carefully around `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, because all three stories extend the same shared bundle contract.
|
||||
Loading…
Reference in New Issue
Block a user