Compare commits

..

4 Commits

Author SHA1 Message Date
Ahmed Darrazi
3ca722a125 fix(ui): render support diagnostics as labeled menu item; add support-request scaffold and tests
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m30s
2026-04-27 13:23:35 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
101 changed files with 3981 additions and 11305 deletions

View File

@ -260,8 +260,6 @@ ## Active Technologies
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -296,9 +294,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
- 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
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

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

View File

@ -1,497 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ReviewPackStatus;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class CustomerReviewWorkspace extends Page implements HasTable
{
use InteractsWithTable;
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
private const string SOURCE_SURFACE = 'customer_review_workspace';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Customer reviews';
protected static ?int $navigationSort = 44;
protected static ?string $title = 'Customer Review Workspace';
protected static ?string $slug = 'reviews/workspace';
protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function tenantPrefilterUrl(Tenant $tenant): string
{
$tenantIdentifier = filled($tenant->external_id)
? (string) $tenant->external_id
: (string) $tenant->getKey();
return static::getUrl(panel: 'admin').'?'.http_build_query([
'tenant' => $tenantIdentifier,
]);
}
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public function mount(): void
{
$this->authorizePageAccess();
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void {
$this->clearWorkspaceFilters();
}),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->workspaceQuery())
->defaultSort('name')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
TextColumn::make('latest_review')
->label('Latest review')
->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(),
TextColumn::make('finding_summary')
->label('Key findings')
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label('Accepted risks')
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('published_at')
->label('Published')
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label('Review pack')
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder {
$tenantId = $data['value'] ?? null;
return is_numeric($tenantId)
? $query->whereKey((int) $tenantId)
: $query;
})
->searchable(),
])
->actions([
Action::make('open_latest_review')
->label('Open latest review')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack')
->label('Download review pack')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
])
->bulkActions([])
->emptyStateHeading('No entitled tenants match this view')
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
->emptyStateActions([
Action::make('clear_filters_empty')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(fn (): mixed => $this->clearWorkspaceFilters()),
]);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
throw new NotFoundHttpException;
}
if ($this->authorizedTenants() === []) {
throw new NotFoundHttpException;
}
}
private function workspaceQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Tenant::query()->whereRaw('1 = 0');
}
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function defaultTenantFilter(): ?string
{
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
? (string) $tenantId
: null;
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function hasActiveFilters(): bool
{
return $this->currentTenantFilterId() !== null;
}
private function clearWorkspaceFilters(): void
{
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilters();
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_numeric($workspaceId)
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
private function latestPublishedReview(Tenant $tenant): ?TenantReview
{
$review = $tenant->tenantReviews->first();
return $review instanceof TenantReview ? $review : null;
}
private function latestReviewUrl(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return null;
}
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
self::DETAIL_CONTEXT_QUERY_KEY => 1,
]);
}
private function latestReviewPack(Tenant $tenant): ?ReviewPack
{
$review = $this->latestPublishedReview($tenant);
$pack = $review?->currentExportReviewPack;
return $pack instanceof ReviewPack ? $pack : null;
}
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
{
$user = auth()->user();
$pack = $this->latestReviewPack($tenant);
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => self::SOURCE_SURFACE,
]);
}
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
{
return $this->latestPublishedReview($tenant)?->published_at;
}
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
{
$review = $this->latestPublishedReview($tenant);
return $review instanceof TenantReview
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
: null;
}
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);
$review = $this->latestPublishedReview($tenant);
$truth = $this->reviewTruth($tenant);
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
}
private function latestReviewStateLabel(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
}
private function latestReviewStateColor(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
}
private function latestReviewStateIcon(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
}
private function latestReviewStateIconColor(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
$summary = is_array($review->summary) ? $review->summary : [];
$findingOutcomes = $summary['finding_outcomes'] ?? null;
if (! is_array($findingOutcomes)) {
return $primaryReason;
}
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$summary = is_array($review->summary) ? $review->summary : [];
$findingCount = (int) ($summary['finding_count'] ?? 0);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) {
return 'No findings recorded in the published review.';
}
if ($terminalOutcomes === null) {
return sprintf('%d findings summarized in the published review.', $findingCount);
}
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
}
private function acceptedRiskSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$summary = is_array($review->summary) ? $review->summary : [];
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
return match (true) {
$statusMarkedCount === 0 => 'No accepted risks recorded.',
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
};
}
private function reviewPackAvailability(Tenant $tenant): string
{
$pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) {
return 'Unavailable';
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return 'Unavailable';
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return 'Unavailable';
}
return 'Available';
}
}

View File

@ -9,7 +9,6 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
@ -177,10 +176,6 @@ public function table(Table $table): Table
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
: null)
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
])
->bulkActions([])

View File

@ -7,11 +7,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
@ -24,9 +20,7 @@
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -57,7 +51,6 @@ class WorkspaceSettings extends Page
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/
private const SETTING_FIELDS = [
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
@ -65,23 +58,10 @@ class WorkspaceSettings extends Page
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
];
/**
* @var array<string, string>
*/
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
@ -131,14 +111,6 @@ class WorkspaceSettings extends Page
*/
public array $resolvedSettings = [];
/**
* @var array{
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions?: array<string, array<string, mixed>>
* }
*/
public array $entitlementSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
@ -208,71 +180,6 @@ public function content(Schema $schema): Schema
return $schema
->statePath('data')
->schema([
Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2)
->schema([
Select::make('entitlements_plan_profile')
->label('Plan profile')
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
->native(false)
->columnSpanFull()
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->planProfileFieldHelperText()),
TextInput::make('entitlements_managed_tenant_limit_override_value')
->label('Managed tenant activation limit override')
->placeholder('Unset (uses plan profile default)')
->suffix('tenants')
->hint('0 or greater')
->numeric()
->integer()
->minValue(0)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitHelperText())
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
Textarea::make('entitlements_managed_tenant_limit_override_reason')
->label('Managed tenant activation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
Select::make('entitlements_review_pack_generation_override_value')
->label('Review pack generation override')
->options(self::booleanOptions())
->placeholder('Unset (uses plan profile default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
Textarea::make('entitlements_review_pack_generation_override_reason')
->label('Review pack generation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
]),
Section::make('Workspace AI policy')
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
->schema([
Select::make('ai_policy_mode')
->label('AI posture')
->options(AiPolicyMode::optionLabels())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->aiPolicyModeHelperText())
->hintAction($this->makeResetAction('ai_policy_mode')),
Placeholder::make('ai_approved_use_cases')
->label('Approved use cases')
->content(fn (): string => $this->aiApprovedUseCasesText()),
Placeholder::make('ai_allowed_provider_classes')
->label('Allowed provider classes')
->content(fn (): string => $this->aiAllowedProviderClassesText()),
Placeholder::make('ai_blocked_data_classifications')
->label('Blocked data classifications')
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
]),
Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([
@ -548,56 +455,6 @@ public function resetSetting(string $field): void
->send();
}
private function resetEntitlementOverridePair(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
if (! $this->hasEntitlementOverridePair($field)) {
Notification::make()
->title('Entitlement already uses plan profile default')
->success()
->send();
return;
}
$writer = app(SettingsWriter::class);
$valueSetting = $this->settingForField($field);
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
$reasonSetting = $this->settingForField($reasonField);
if ($this->workspaceOverrideForField($field) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $valueSetting['domain'],
key: $valueSetting['key'],
);
}
if ($this->workspaceOverrideForField($reasonField) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $reasonSetting['domain'],
key: $reasonSetting['key'],
);
}
$this->loadFormState();
Notification::make()
->title('Workspace entitlement override reset')
->success()
->send();
}
private function loadFormState(): void
{
$resolver = app(SettingsResolver::class);
@ -633,7 +490,6 @@ private function loadFormState(): void
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
$this->loadDomainLastModified();
}
@ -707,25 +563,15 @@ private function makeResetAction(string $field): Action
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
if ($this->isEntitlementOverrideValueField($field)) {
$this->resetEntitlementOverridePair($field);
return;
}
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.';
}
if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) {
return 'No workspace override to reset.';
}
if (! $this->hasWorkspaceOverride($field)) {
return 'No workspace override to reset.';
}
@ -733,200 +579,6 @@ private function makeResetAction(string $field): Action
});
}
private function canResetField(string $field): bool
{
if ($this->isEntitlementOverrideValueField($field)) {
return $this->hasEntitlementOverridePair($field);
}
return $this->hasWorkspaceOverride($field);
}
private function isEntitlementOverrideValueField(string $field): bool
{
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
}
private function hasEntitlementOverridePair(string $field): bool
{
if (! $this->isEntitlementOverrideValueField($field)) {
return false;
}
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
return $this->workspaceOverrideForField($field) !== null
|| $this->workspaceOverrideForField($reasonField) !== null;
}
private function planProfileFieldHelperText(): string
{
$profile = $this->resolvedPlanProfile();
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
if (! is_string($selectedProfile) || $selectedProfile === '') {
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
}
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
}
private function managedTenantLimitHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$capacityText = $remainingCapacity < 0
? sprintf('Over limit by %d.', abs($remainingCapacity))
: sprintf('%d remaining.', $remainingCapacity);
return sprintf(
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
$effectiveValue,
$currentUsage,
$capacityText,
$this->entitlementSourceLabel($decision),
);
}
private function managedTenantLimitReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_managed_tenant_limit_override_value',
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function reviewPackGenerationHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
return sprintf(
'Effective state: %s. Source: %s.',
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
$this->entitlementSourceLabel($decision),
);
}
private function reviewPackGenerationReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_review_pack_generation_override_value',
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function aiPolicyModeHelperText(): string
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return '';
}
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
: sprintf('Effective posture: %s.', $mode->label());
return sprintf('%s %s', $prefix, $mode->summary());
}
private function aiApprovedUseCasesText(): string
{
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
}
private function aiAllowedProviderClassesText(): string
{
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
if ($labels === []) {
return 'No provider classes are allowed while AI is disabled.';
}
return implode(', ', $labels).'.';
}
private function aiBlockedDataClassificationsText(): string
{
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
}
private function effectiveAiPolicyMode(): AiPolicyMode
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return AiPolicyMode::Disabled;
}
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
}
private function entitlementReasonHelperText(string $valueField, string $key): string
{
$decision = $this->entitlementDecision($key);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
if ($this->workspaceOverrideForField($valueField) === null) {
return 'Required when an explicit override value is set.';
}
if ($rationale === null || $rationale === '') {
return 'Required when an explicit override value is set.';
}
return sprintf('Current rationale: %s', $rationale);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
private function resolvedPlanProfile(): array
{
$profile = $this->entitlementSummary['plan_profile'] ?? null;
if (is_array($profile)) {
return $profile;
}
return app(WorkspacePlanProfileCatalog::class)->default();
}
/**
* @return array<string, mixed>
*/
private function entitlementDecision(string $key): array
{
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
return is_array($decision) ? $decision : [];
}
/**
* @param array<string, mixed> $decision
*/
private function entitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$planProfileLabel = $decision['plan_profile_label'] ?? null;
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
return sprintf('%s plan profile', $planProfileLabel);
}
return 'plan profile default';
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
@ -1069,27 +721,6 @@ private function normalizedInputValues(): array
}
}
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
if (($normalizedValues[$valueField] ?? null) === null) {
$normalizedValues[$reasonField] = null;
continue;
}
if (($normalizedValues[$reasonField] ?? null) !== null) {
continue;
}
$message = match ($valueField) {
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
default => 'Override reason is required when an explicit override is set.',
};
$validationErrors['data.'.$reasonField] ??= [];
$validationErrors['data.'.$reasonField][] = $message;
}
return [$normalizedValues, $validationErrors];
}

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationRegistry;
@ -663,16 +662,7 @@ public function content(Schema $schema): Schema
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()),
Text::make('Activation entitlement')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryEntitlementSummary())
->badge()
->color(fn (): string => $this->completionSummaryEntitlementColor()),
]),
Callout::make('Activation entitlement')
->description(fn (): string => $this->completionSummaryEntitlementDetail())
->warning()
->visible(fn (): bool => $this->completionSummaryEntitlementBlocked()),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
@ -710,7 +700,9 @@ public function content(Schema $schema): Schema
->modalSubmitActionLabel('Yes, complete onboarding')
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
->tooltip(fn (): ?string => $this->completionActionTooltip())
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
? null
: 'Owner required to complete onboarding.')
->action(fn () => $this->completeOnboarding()),
]),
]),
@ -4506,10 +4498,6 @@ private function canCompleteOnboarding(): bool
return false;
}
if ($this->completionSummaryEntitlementBlocked()) {
return false;
}
$user = $this->currentUser();
if (! app(TenantOperabilityService::class)->outcomeFor(
@ -4542,111 +4530,6 @@ private function canCompleteOnboarding(): bool
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
}
/**
* @return array<string, mixed>
*/
private function completionSummaryEntitlementDecision(): array
{
if (! isset($this->workspace) || ! $this->workspace instanceof Workspace) {
return [];
}
return app(WorkspaceEntitlementResolver::class)->resolve(
$this->workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function completionSummaryEntitlementBlocked(): bool
{
return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
}
private function completionSummaryEntitlementSummary(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
return sprintf(
'%s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$currentUsage,
$effectiveValue,
$sourceLabel,
);
}
private function completionSummaryEntitlementDetail(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf(
'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
$currentUsage,
$currentUsage === 1 ? '' : 's',
$effectiveValue,
$sourceLabel,
);
if ($remainingCapacity >= 0) {
$message .= sprintf(' Remaining capacity: %d.', $remainingCapacity);
}
if ($this->completionSummaryEntitlementBlocked()) {
$blockReason = is_string($decision['block_reason'] ?? null) ? $decision['block_reason'] : null;
if ($blockReason !== null && $blockReason !== '') {
$message = $blockReason;
}
}
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
$message .= ' Rationale: '.$rationale;
}
return $message;
}
private function completionSummaryEntitlementColor(): string
{
return $this->completionSummaryEntitlementBlocked() ? 'warning' : 'success';
}
/**
* @param array<string, mixed> $decision
*/
private function completionSummaryEntitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$label = $decision['plan_profile_label'] ?? null;
return is_string($label) && $label !== ''
? sprintf('%s plan profile', $label)
: 'plan profile default';
}
private function completionActionTooltip(): ?string
{
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
return 'Owner required to complete onboarding.';
}
if ($this->completionSummaryEntitlementBlocked()) {
return $this->completionSummaryEntitlementDetail();
}
return null;
}
private function completionSummaryTenantLine(): string
{
$tenant = $this->currentManagedTenantRecord();
@ -4980,16 +4863,6 @@ public function completeOnboarding(): void
return;
}
if ($this->completionSummaryEntitlementBlocked()) {
Notification::make()
->title('Activation limit reached')
->body($this->completionSummaryEntitlementDetail())
->warning()
->send();
return;
}
$run = $this->verificationRun();
$verificationSucceeded = $this->verificationHasSucceeded();
$verificationCanProceed = $this->verificationCanProceed();

View File

@ -6,7 +6,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot;
@ -268,20 +267,6 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
)->toArray();
}
if ($record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'customer_review_workspace',
label: 'Customer workspace',
value: $record->tenant->name,
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
targetKind: 'canonical_page',
priority: 30,
actionLabel: 'Open customer workspace',
contextBadge: 'Reporting',
)->toArray();
}
return $entries;
}

View File

@ -2,10 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack;
@ -13,7 +10,6 @@
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -49,8 +45,6 @@
class ReviewPackResource extends Resource
{
use ResolvesPanelTenantContext;
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -108,9 +102,9 @@ public static function canView(Model $record): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
@ -196,13 +190,6 @@ public static function infolist(Schema $schema): Schema
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
: null)
->placeholder('—'),
TextEntry::make('customer_workspace')
->label('Customer workspace')
->state(fn (): string => 'Open workspace')
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
: null)
->placeholder('—'),
TextEntry::make('summary.review_status')
->label('Review status')
->badge()
@ -363,62 +350,41 @@ public static function table(Table $table): Table
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
static::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
]);
}
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
{
$action = UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
->icon('heroicon-o-plus')
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
->action(function (array $data): void {
static::executeGeneration($data);
})
->form(static::reviewPackGenerationFormSchema())
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
return $action;
}
/**
* @return array<int, Section>
*/
public static function reviewPackGenerationFormSchema(): array
{
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
];
}
public static function getEloquentQuery(): Builder
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
->where('tenant_id', (int) $tenant->getKey());
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
@ -492,14 +458,6 @@ public static function executeGeneration(array $data): void
try {
$reviewPack = $service->generate($tenant, $user, $options);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->warning()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->send();
return;
} catch (ReviewPackEvidenceResolutionException $exception) {
$reasons = $exception->result->reasons;
@ -535,55 +493,4 @@ public static function executeGeneration(array $data): void
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function currentTenantContext(): ?Tenant
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
return $tenant instanceof Tenant ? $tenant : null;
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::currentTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant);
}
}

View File

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

View File

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

View File

@ -6,9 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
@ -17,7 +15,6 @@
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -244,25 +241,6 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
return $table
->defaultSort('generated_at', 'desc')
->persistFiltersInSession()
@ -309,7 +287,20 @@ public static function table(Table $table): Table
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
])
->actions([
$exportExecutivePackAction,
UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
->bulkActions([])
->emptyStateHeading('No tenant reviews yet')
@ -432,50 +423,6 @@ public static function executeCreateReview(array $data): void
$toast->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::panelTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant);
}
public static function executeExport(TenantReview $review): void
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
@ -510,10 +457,6 @@ public static function executeExport(TenantReview $review): void
'include_pii' => true,
'include_operations' => true,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
return;
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
@ -650,15 +593,6 @@ private static function summaryContextLinks(TenantReview $record): array
];
}
if ($record->tenant) {
$links[] = [
'title' => 'Customer workspace',
'label' => 'Open customer workspace',
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
];
}
if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [
'title' => 'Evidence snapshot',

View File

@ -4,15 +4,12 @@
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
@ -27,13 +24,6 @@ class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceOpen();
}
protected function resolveRecord(int|string $key): Model
{
return TenantReviewResource::resolveScopedRecordOrFail($key);
@ -79,7 +69,7 @@ protected function getHeaderActions(): array
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
]));
}
@ -95,10 +85,6 @@ private function primaryLifecycleAction(): ?Actions\Action
private function primaryLifecycleActionName(): ?string
{
if ($this->isCustomerWorkspaceView()) {
return null;
}
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
@ -136,10 +122,6 @@ private function secondaryLifecycleActions(): array
*/
private function secondaryLifecycleActionNames(): array
{
if ($this->isCustomerWorkspaceView()) {
return [];
}
$names = [];
if ($this->record->isMutable()) {
@ -196,6 +178,7 @@ private function refreshReviewAction(): Actions\Action
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
@ -249,7 +232,7 @@ private function publishReviewAction(): Actions\Action
private function exportExecutivePackAction(): Actions\Action
{
$action = UiEnforcement::forAction(
return UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
@ -258,17 +241,11 @@ private function exportExecutivePackAction(): Actions\Action
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
return $action;
}
private function createNextReviewAction(): Actions\Action
@ -342,39 +319,4 @@ private function archiveReviewAction(): Actions\Action
->preserveVisibility()
->apply();
}
private function isCustomerWorkspaceView(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
private function auditCustomerWorkspaceOpen(): void
{
if (! $this->isCustomerWorkspaceView()) {
return;
}
$user = auth()->user();
$tenant = $this->record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::TenantReviewOpened,
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $this->record->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
tenant: $tenant,
);
}
}

View File

@ -9,7 +9,6 @@
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
@ -86,14 +85,6 @@ public function runsUrl(): string
return SystemOperationRunLinks::index();
}
/**
* @return array<string, mixed>
*/
public function workspaceEntitlementSummary(): array
{
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},

View File

@ -80,9 +80,6 @@ protected function getHeaderActions(): array
$this->pauseRestoreExecuteAction(),
$this->resumeRestoreExecuteAction(),
$this->viewHistoryRestoreExecuteAction(),
$this->pauseAiExecutionAction(),
$this->resumeAiExecutionAction(),
$this->viewHistoryAiExecutionAction(),
];
}
@ -202,21 +199,6 @@ public function viewHistoryRestoreExecuteAction(): Action
return $this->historyActionFor('restore.execute');
}
public function pauseAiExecutionAction(): Action
{
return $this->pauseActionFor('ai.execution');
}
public function resumeAiExecutionAction(): Action
{
return $this->resumeActionFor('ai.execution');
}
public function viewHistoryAiExecutionAction(): Action
{
return $this->historyActionFor('ai.execution');
}
private function pauseActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
@ -231,7 +213,7 @@ private function pauseActionFor(string $controlKey): Action
->form($this->pauseFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($data);
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
@ -291,7 +273,7 @@ private function resumeActionFor(string $controlKey): Action
->form($this->resumeFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
[$scopeType, $workspace] = $this->normalizeResumeInput($data);
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
->notExpired()
@ -349,8 +331,11 @@ private function pauseFormSchema(string $controlKey): array
return [
Radio::make('scope_type')
->label('Scope')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->live()
->required(),
@ -410,8 +395,11 @@ private function resumeFormSchema(string $controlKey): array
return [
Radio::make('scope_type')
->label('Scope')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->live()
->required(),
@ -468,9 +456,9 @@ private function controlsActor(): PlatformUser
/**
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
*/
private function normalizePauseInput(string $controlKey, array $data): array
private function normalizePauseInput(array $data): array
{
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
[$scopeType, $workspace] = $this->resolveScopeInput($data);
$reasonText = trim((string) ($data['reason_text'] ?? ''));
if ($reasonText === '') {
@ -497,20 +485,19 @@ private function normalizePauseInput(string $controlKey, array $data): array
/**
* @return array{0: string, 1: ?Workspace}
*/
private function normalizeResumeInput(string $controlKey, array $data): array
private function normalizeResumeInput(array $data): array
{
return $this->resolveScopeInput($controlKey, $data);
return $this->resolveScopeInput($data);
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function resolveScopeInput(string $controlKey, array $data): array
private function resolveScopeInput(array $data): array
{
$scopeType = (string) ($data['scope_type'] ?? 'global');
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
if (! in_array($scopeType, $supportedScopes, true)) {
if (! in_array($scopeType, ['global', 'workspace'], true)) {
throw ValidationException::withMessages([
'scope_type' => 'Invalid scope selected.',
]);
@ -539,26 +526,6 @@ private function resolveScopeInput(string $controlKey, array $data): array
return [$scopeType, $workspace];
}
/**
* @return array<string, string>
*/
private function scopeOptions(string $controlKey): array
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return Arr::only([
'global' => 'Global',
'workspace' => 'One workspace',
], $supportedScopes);
}
private function defaultScopeFor(string $controlKey): string
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return $supportedScopes[0] ?? 'global';
}
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
{
$query = OperationalControlActivation::query()

View File

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

View File

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

View File

@ -12,7 +12,6 @@
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Pages\WorkspaceOverview;
@ -184,7 +183,6 @@ public function panel(Panel $panel): Panel
FindingsIntakeQueue::class,
MyFindingsInbox::class,
FindingExceptionsQueue::class,
CustomerReviewWorkspace::class,
ReviewRegister::class,
])
->widgets([

View File

@ -1,327 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use Carbon\CarbonInterface;
final class WorkspaceEntitlementResolver
{
public const SETTING_DOMAIN = 'entitlements';
public const SETTING_PLAN_PROFILE = 'plan_profile';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspacePlanProfileCatalog $planProfileCatalog,
) {}
/**
* @return array{
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions: array<string, array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }>
* }
*/
public function summary(Workspace $workspace): array
{
$planProfile = $this->resolvePlanProfile($workspace);
return [
'plan_profile' => $planProfile,
'decisions' => [
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
],
];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolvePlanProfile(Workspace $workspace): array
{
$planProfileId = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_PLAN_PROFILE,
);
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
{
$planProfile ??= $this->resolvePlanProfile($workspace);
$lastChanged = $this->lastChangedMetadata($workspace);
return match ($key) {
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
};
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int,
* remaining_capacity: int,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
);
$effectiveValue = is_int($overrideValue['value'])
? $overrideValue['value']
: (int) $planProfile['managed_tenant_limit_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$currentUsage = Tenant::activeQuery()
->where('workspace_id', (int) $workspace->getKey())
->count();
$remainingCapacity = $effectiveValue - $currentUsage;
$isBlocked = $currentUsage >= $effectiveValue;
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => $currentUsage,
'remaining_capacity' => $remainingCapacity,
'is_blocked' => $isBlocked,
'block_reason' => $isBlocked
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
: null,
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: null,
* remaining_capacity: null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
);
$effectiveValue = is_bool($overrideValue['value'])
? $overrideValue['value']
: (bool) $planProfile['review_pack_generation_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => null,
'remaining_capacity' => null,
'is_blocked' => ! $effectiveValue,
'block_reason' => $effectiveValue
? null
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_PLAN_PROFILE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
])
->whereNotNull('updated_by_user_id')
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
{
$prefix = $source === 'workspace_override'
? 'This workspace override currently allows'
: sprintf('The %s plan profile currently allows', $planProfile['label']);
$message = sprintf(
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
$prefix,
$effectiveValue,
$effectiveValue === 1 ? '' : 's',
$currentUsage,
$currentUsage === 1 ? '' : 's',
);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
{
$message = $source === 'workspace_override'
? 'Review pack generation is disabled by workspace override.'
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
}

View File

@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
final class WorkspacePlanProfileCatalog
{
/**
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
private const PROFILES = [
'starter' => [
'id' => 'starter',
'label' => 'Starter',
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
'is_default' => false,
],
'standard' => [
'id' => 'standard',
'label' => 'Standard',
'description' => 'Balanced defaults for most managed workspaces.',
'managed_tenant_limit_default' => 25,
'review_pack_generation_default' => true,
'is_default' => true,
],
'scale' => [
'id' => 'scale',
'label' => 'Scale',
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
'managed_tenant_limit_default' => 100,
'review_pack_generation_default' => true,
'is_default' => false,
],
];
/**
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
public function all(): array
{
return array_values(self::PROFILES);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function default(): array
{
return self::PROFILES[self::defaultProfileId()];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
*/
public function find(?string $id): ?array
{
if ($id === null) {
return null;
}
return self::PROFILES[$id] ?? null;
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolve(?string $id): array
{
return $this->find($id) ?? $this->default();
}
/**
* @return array<string, string>
*/
public function optionLabels(): array
{
return array_map(
static fn (array $profile): string => $profile['label'],
self::PROFILES,
);
}
/**
* @return list<string>
*/
public static function profileIds(): array
{
return array_keys(self::PROFILES);
}
public static function defaultProfileId(): string
{
foreach (self::PROFILES as $id => $profile) {
if ($profile['is_default']) {
return $id;
}
}
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
}
}

View File

@ -4,7 +4,6 @@
namespace App\Services;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot;
@ -14,7 +13,6 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId;
@ -30,7 +28,6 @@ public function __construct(
private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger,
private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private ProductTelemetryRecorder $productTelemetryRecorder,
) {}
@ -52,8 +49,6 @@ public function __construct(
*/
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
{
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$snapshot = $this->resolveSnapshot($tenant);
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
@ -143,8 +138,6 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
}
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$fingerprint = $this->computeFingerprintForReview($review, $options);
$existing = $this->findExistingPackForReview($review, $fingerprint);
@ -234,31 +227,18 @@ public function computeFingerprint(Tenant $tenant, array $options): string
/**
* Generate a signed download URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
public function generateDownloadUrl(ReviewPack $pack): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.download',
array_merge(['reviewPack' => $pack->getKey()], $parameters),
['reviewPack' => $pack->getKey()],
now()->addMinutes($ttlMinutes),
);
}
/**
* @return array<string, mixed>
*/
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{
return $this->workspaceEntitlementResolver->resolve(
$tenant->workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
{
$this->productTelemetryRecorder->record(
@ -334,17 +314,6 @@ private function normalizeOptions(array $options): array
];
}
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
{
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return;
}
throw new WorkspaceEntitlementBlockedException($decision);
}
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
{
$data = [

View File

@ -12,7 +12,6 @@
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
final class TenantReviewRegisterService
{
@ -44,55 +43,6 @@ public function query(User $user, Workspace $workspace): Builder
->latest('id');
}
public function latestPublishedQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
$rankedReviews = TenantReview::query()
->select([
'tenant_reviews.id',
'tenant_reviews.tenant_id',
'tenant_reviews.published_at',
'tenant_reviews.generated_at',
])
->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn')
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->published();
$latestPublishedIds = DB::query()
->fromSub($rankedReviews, 'ranked_tenant_reviews')
->where('rn', 1)
->select('id');
return TenantReview::query()
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_reviews.id', $latestPublishedIds)
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id');
}
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
return Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
->with([
'tenantReviews' => fn ($query) => $query
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->published()
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id')
->limit(1),
])
->orderBy('name');
}
public function canAccessWorkspace(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiDataClassification: string
{
case ProductKnowledge = 'product_knowledge';
case OperationalMetadata = 'operational_metadata';
case RedactedSupportSummary = 'redacted_support_summary';
case PersonalData = 'personal_data';
case CustomerConfidential = 'customer_confidential';
case RawProviderPayload = 'raw_provider_payload';
public function label(): string
{
return match ($this) {
self::ProductKnowledge => 'Product knowledge',
self::OperationalMetadata => 'Operational metadata',
self::RedactedSupportSummary => 'Redacted support summary',
self::PersonalData => 'Personal data',
self::CustomerConfidential => 'Customer confidential',
self::RawProviderPayload => 'Raw provider payload',
};
}
}

View File

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
final class AiDecisionAuditMetadataFactory
{
/**
* @return array<string, mixed>
*/
public function make(AiExecutionRequest $request, AiExecutionDecision $decision): array
{
return array_filter([
'use_case_key' => $decision->useCaseKey,
'decision_outcome' => $decision->outcome,
'decision_reason' => $decision->reasonCode->value,
'workspace_ai_policy_mode' => $decision->workspaceAiPolicyMode,
'requested_provider_class' => $decision->requestedProviderClass,
'data_classifications' => $decision->dataClassifications,
'source_family' => $decision->sourceFamily,
'workspace_id' => $request->workspace?->getKey(),
'tenant_id' => $request->tenant?->getKey(),
'context_fingerprint' => $this->normalizedFingerprint($request->contextFingerprint),
'matched_operational_control_scope' => $decision->matchedOperationalControlScope,
], static fn (mixed $value): bool => $value !== null);
}
private function normalizedFingerprint(?string $contextFingerprint): ?string
{
if (! is_string($contextFingerprint)) {
return null;
}
$normalized = trim($contextFingerprint);
return $normalized === '' ? null : $normalized;
}
}

View File

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiDecisionReasonCode: string
{
case Allowed = 'allowed';
case MissingWorkspaceContext = 'missing_workspace_context';
case TenantOutsideWorkspace = 'tenant_outside_workspace';
case OperationalControlPaused = 'operational_control_paused';
case WorkspacePolicyDisabled = 'workspace_policy_disabled';
case UnregisteredUseCase = 'unregistered_use_case';
case ProviderClassBlocked = 'provider_class_blocked';
case DataClassificationBlocked = 'data_classification_blocked';
case SourceFamilyMismatch = 'source_family_mismatch';
}

View File

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Support\Audit\AuditActionId;
final readonly class AiExecutionDecision
{
/**
* @param list<string> $dataClassifications
* @param array<string, mixed> $auditMetadata
*/
public function __construct(
public string $outcome,
public AiDecisionReasonCode $reasonCode,
public string $workspaceAiPolicyMode,
public ?string $matchedOperationalControlScope,
public string $useCaseKey,
public string $requestedProviderClass,
public array $dataClassifications,
public string $sourceFamily,
public AuditActionId $auditAction,
public array $auditMetadata,
) {}
public function isAllowed(): bool
{
return $this->outcome === 'allowed';
}
public function isBlocked(): bool
{
return $this->outcome === 'blocked';
}
}

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
final readonly class AiExecutionRequest
{
/**
* @param list<string> $dataClassifications
*/
public function __construct(
public ?Workspace $workspace,
public ?Tenant $tenant,
public User|PlatformUser|null $actor,
public string $useCaseKey,
public string $requestedProviderClass,
public array $dataClassifications,
public string $sourceFamily,
public ?string $callerSurface = null,
public ?string $contextFingerprint = null,
) {}
}

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiPolicyMode: string
{
case Disabled = 'disabled';
case PrivateOnly = 'private_only';
public function label(): string
{
return match ($this) {
self::Disabled => 'Disabled',
self::PrivateOnly => 'Private only',
};
}
public function summary(): string
{
return match ($this) {
self::Disabled => 'No AI execution is allowed for this workspace.',
self::PrivateOnly => 'Only approved internal drafts may use private-only AI for approved use cases.',
};
}
/**
* @return array<string, string>
*/
public static function optionLabels(): array
{
return array_reduce(
self::cases(),
static function (array $labels, self $mode): array {
$labels[$mode->value] = $mode->label();
return $labels;
},
[],
);
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiProviderClass: string
{
case LocalPrivate = 'local_private';
case ExternalPublic = 'external_public';
public function label(): string
{
return match ($this) {
self::LocalPrivate => 'Local private',
self::ExternalPublic => 'External public',
};
}
}

View File

@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
final class AiUseCaseCatalog
{
/**
* @var array<string, array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }>
*/
private const USE_CASES = [
'product_knowledge.answer_draft' => [
'key' => 'product_knowledge.answer_draft',
'label' => 'Product knowledge answer draft',
'future_consumer' => 'ContextualHelpResolver',
'visibility' => 'internal_only_draft',
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
'allowed_data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
'source_family' => 'product_knowledge',
'tenant_context_permitted' => false,
],
'support_diagnostics.summary_draft' => [
'key' => 'support_diagnostics.summary_draft',
'label' => 'Support diagnostics summary draft',
'future_consumer' => 'SupportDiagnosticBundleBuilder',
'visibility' => 'internal_only_draft',
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
'allowed_data_classifications' => [AiDataClassification::RedactedSupportSummary->value],
'source_family' => 'support_diagnostics',
'tenant_context_permitted' => true,
],
];
/**
* @return list<array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }>
*/
public function all(): array
{
return array_values(self::USE_CASES);
}
/**
* @return array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }|null
*/
public function find(string $key): ?array
{
return self::USE_CASES[$key] ?? null;
}
/**
* @return list<string>
*/
public function labels(): array
{
return array_map(
static fn (array $definition): string => $definition['label'],
$this->all(),
);
}
/**
* @return list<string>
*/
public function allowedProviderClassLabelsForMode(AiPolicyMode $mode): array
{
if ($mode === AiPolicyMode::Disabled) {
return [];
}
$labels = [];
foreach ($this->all() as $definition) {
foreach ($definition['allowed_provider_classes'] as $providerClass) {
$labels[$providerClass] = AiProviderClass::from($providerClass)->label();
}
}
return array_values($labels);
}
/**
* @return list<string>
*/
public function blockedDataClassificationLabels(): array
{
return array_map(
static fn (AiDataClassification $classification): string => $classification->label(),
[
AiDataClassification::PersonalData,
AiDataClassification::CustomerConfidential,
AiDataClassification::RawProviderPayload,
],
);
}
}

View File

@ -1,181 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use App\Support\OperationalControls\OperationalControlEvaluator;
final class GovernedAiExecutionBoundary
{
public function __construct(
private readonly AiUseCaseCatalog $useCaseCatalog,
private readonly SettingsResolver $settingsResolver,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
{
$decision = $this->decisionFor($request);
$metadata = $this->auditMetadataFactory->make($request, $decision);
$decision = new AiExecutionDecision(
outcome: $decision->outcome,
reasonCode: $decision->reasonCode,
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
useCaseKey: $decision->useCaseKey,
requestedProviderClass: $decision->requestedProviderClass,
dataClassifications: $decision->dataClassifications,
sourceFamily: $decision->sourceFamily,
auditAction: $decision->auditAction,
auditMetadata: $metadata,
);
if ($request->workspace !== null) {
$definition = $this->useCaseCatalog->find($request->useCaseKey);
$this->workspaceAuditLogger->log(
workspace: $request->workspace,
action: $decision->auditAction,
context: ['metadata' => $decision->auditMetadata],
actor: $request->actor,
status: $decision->isAllowed() ? 'success' : 'blocked',
resourceType: 'ai_use_case',
resourceId: $request->useCaseKey,
targetLabel: $definition['label'] ?? $request->useCaseKey,
summary: 'AI execution decision evaluated',
tenant: $request->tenant,
);
}
return $decision;
}
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
{
if ($request->workspace === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
if ($controlDecision->isPaused()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
matchedOperationalControlScope: $controlDecision->matchedScopeType,
);
}
$policyMode = $this->resolvedPolicyMode($request);
if ($policyMode === AiPolicyMode::Disabled->value) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
workspaceAiPolicyMode: $policyMode,
);
}
$definition = $this->useCaseCatalog->find($request->useCaseKey);
if ($definition === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
workspaceAiPolicyMode: $policyMode,
);
}
if ($definition['source_family'] !== $request->sourceFamily) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
workspaceAiPolicyMode: $policyMode,
);
}
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
foreach ($request->dataClassifications as $classification) {
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
}
return new AiExecutionDecision(
outcome: 'allowed',
reasonCode: AiDecisionReasonCode::Allowed,
workspaceAiPolicyMode: $policyMode,
matchedOperationalControlScope: null,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
private function resolvedPolicyMode(AiExecutionRequest $request): string
{
if ($request->workspace === null) {
return AiPolicyMode::Disabled->value;
}
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
return is_string($resolved) && $resolved !== ''
? $resolved
: AiPolicyMode::Disabled->value;
}
private function blockedDecision(
AiExecutionRequest $request,
AiDecisionReasonCode $reasonCode,
string $workspaceAiPolicyMode,
?string $matchedOperationalControlScope = null,
): AiExecutionDecision {
return new AiExecutionDecision(
outcome: 'blocked',
reasonCode: $reasonCode,
workspaceAiPolicyMode: $workspaceAiPolicyMode,
matchedOperationalControlScope: $matchedOperationalControlScope,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
}

View File

@ -94,16 +94,13 @@ enum AuditActionId: string
case TenantReviewRefreshed = 'tenant_review.refreshed';
case TenantReviewPublished = 'tenant_review.published';
case TenantReviewArchived = 'tenant_review.archived';
case TenantReviewOpened = 'tenant_review.opened';
case TenantReviewExported = 'tenant_review.exported';
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
case ReviewPackDownloaded = 'review_pack.downloaded';
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
case OperationalControlResumed = 'operational_control.resumed';
@ -240,15 +237,12 @@ private static function labels(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',
@ -332,13 +326,10 @@ private static function summaries(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',

View File

@ -17,13 +17,6 @@ final class OperationalControlCatalog
'operation_types' => ['restore.execute'],
'affected_surfaces' => ['tenant.restore_runs.create'],
],
'ai.execution' => [
'key' => 'ai.execution',
'label' => 'AI execution',
'supported_scopes' => ['global'],
'operation_types' => ['ai.execution'],
'affected_surfaces' => ['governed_ai.execution'],
],
];
/**

View File

@ -6,7 +6,6 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Ai\AiDataClassification;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter;
@ -148,43 +147,6 @@ public function knowledgeSource(): array
return $this->catalog->knowledgeSource();
}
/**
* @return array{
* use_case_key: string,
* source_family: string,
* data_classifications: list<string>,
* operational_metadata: array{version: int, topic_count: int},
* topics: list<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{label: string, kind: string, url: ?string, resolver: ?string}>
* }>
* }
*/
public function aiProductKnowledgeAnswerDraftSource(): array
{
$source = $this->knowledgeSource();
return [
'use_case_key' => 'product_knowledge.answer_draft',
'source_family' => 'product_knowledge',
'data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
'operational_metadata' => [
'version' => (int) $source['version'],
'topic_count' => (int) $source['topic_count'],
],
'topics' => $source['topics'],
];
}
/**
* @param array<string, mixed>|null $verificationReport
*/

View File

@ -4,9 +4,7 @@
namespace App\Support\Settings;
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry
{
@ -19,15 +17,6 @@ public function __construct()
{
$this->definitions = [];
$this->register(new SettingDefinition(
domain: 'ai',
key: 'policy_mode',
type: 'string',
systemDefault: AiPolicyMode::Disabled->value,
rules: ['required', 'string', 'in:disabled,private_only'],
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
));
$this->register(new SettingDefinition(
domain: 'backup',
key: 'retention_keep_last_default',
@ -229,91 +218,6 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
rules: ['required', 'integer', 'min:0', 'max:10080'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'plan_profile',
type: 'string',
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
type: 'int',
systemDefault: null,
rules: ['nullable', 'integer', 'min:0'],
normalizer: static function (mixed $value): ?int {
if ($value === null || $value === '') {
return null;
}
return (int) $value;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_value',
type: 'bool',
systemDefault: null,
rules: ['nullable', 'boolean'],
normalizer: static function (mixed $value): ?bool {
if ($value === null || $value === '') {
return null;
}
return filter_var($value, FILTER_VALIDATE_BOOL);
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
}
/**

View File

@ -19,7 +19,6 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
@ -134,39 +133,6 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
);
}
/**
* @return array{
* use_case_key: string,
* source_family: string,
* data_classifications: list<string>,
* summary: array{
* headline: string,
* dominant_issue: string,
* freshness_state: string,
* completeness_note: ?string,
* redaction_note: string,
* generated_from: string
* },
* redaction: array{mode: string, markers: list<string>},
* notes: list<string>
* }
*/
public function aiSupportDiagnosticsSummaryDraftSource(Tenant $tenant, ?User $actor = null): array
{
$bundle = $this->forTenant($tenant, $actor);
return [
'use_case_key' => 'support_diagnostics.summary_draft',
'source_family' => 'support_diagnostics',
'data_classifications' => [
AiDataClassification::RedactedSupportSummary->value,
],
'summary' => $bundle['summary'],
'redaction' => $bundle['redaction'],
'notes' => $bundle['notes'],
];
}
/**
* @param list<array<string, mixed>> $sections
* @return array<string, mixed>

View File

@ -39,7 +39,6 @@
use App\Filament\System\Pages\Dashboard as SystemDashboard;
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Filament\System\Pages\Ops\ViewRun;
use App\Filament\System\Pages\RepairWorkspaceOwners;
@ -662,32 +661,6 @@ public static function spec195ResidualSurfaceInventory(): array
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
Controls::class => [
'surfaceKey' => 'system_ops_controls',
'surfaceName' => 'System Ops Controls',
'pageClass' => Controls::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_utility',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
RepairWorkspaceOwners::class => [
'surfaceKey' => 'repair_workspace_owners',
'surfaceName' => 'Repair Workspace Owners',

View File

@ -1,19 +0,0 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Customer-safe review workspace
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.
</div>
</div>
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -4,11 +4,6 @@
$customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants();
$runs = $this->recentRuns();
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
@endphp
<x-filament-panels::page>
@ -40,58 +35,6 @@
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section>
<x-slot name="heading">
Workspace entitlements
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Plan profile</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $planProfile['label'] }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $planProfile['description'] }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last changed</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $managedTenantDecision['last_changed_by'] ?? 'Not set' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['last_changed_at']?->diffForHumans() ?? 'No entitlement override recorded yet.' }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">Managed tenant activation limit</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['current_usage'] }} active of {{ $managedTenantDecision['effective_value'] }} allowed</p>
</div>
<x-filament::badge :color="$managedTenantDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
{{ $managedTenantDecision['source'] === 'workspace_override' ? 'workspace override' : ($managedTenantDecision['plan_profile_label'].' plan profile') }}
</x-filament::badge>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['rationale'] }}</p>
</div>
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">Review pack generation</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['effective_value'] ? 'Enabled' : 'Disabled' }}</p>
</div>
<x-filament::badge :color="$reviewPackDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
{{ $reviewPackDecision['source'] === 'workspace_override' ? 'workspace override' : ($reviewPackDecision['plan_profile_label'].' plan profile') }}
</x-filament::badge>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['rationale'] }}</p>
</div>
</div>
</x-filament::section>
@endif
<x-filament::section>
<x-slot name="heading">
Tenants summary

View File

@ -9,9 +9,6 @@
/** @var ?string $pollingInterval */
/** @var bool $canView */
/** @var bool $canManage */
/** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */
/** @var ?string $customerWorkspaceUrl */
/** @var ?string $downloadUrl */
/** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
@ -27,12 +24,6 @@
@endif
>
<x-filament::section heading="Review Pack">
@if ($canManage && $generationBlocked && $generationBlockReason)
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
{{ $generationBlockReason }}
</div>
@endif
@if (! $pack)
{{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center">
@ -46,15 +37,12 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate pack
</x-filament::button>
@endif
</div>
@endif
@if ($pack && ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating))
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
{{-- State 2: Queued / Generating --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -75,9 +63,7 @@
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Ready)
@elseif ($statusEnum === ReviewPackStatus::Ready)
{{-- State 3: Ready --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -130,16 +116,13 @@
color="gray"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate new
</x-filament::button>
@endif
</div>
</div>
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Failed)
@elseif ($statusEnum === ReviewPackStatus::Failed)
{{-- State 4: Failed --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -180,15 +163,12 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Retry
</x-filament::button>
@endif
</div>
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Expired)
@elseif ($statusEnum === ReviewPackStatus::Expired)
{{-- State 5: Expired --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -209,25 +189,11 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate new
</x-filament::button>
@endif
</div>
@endif
@if ($canView && $customerWorkspaceUrl)
<div class="mt-3 flex items-center gap-2">
<x-filament::button
size="sm"
color="gray"
tag="a"
:href="$customerWorkspaceUrl"
>
Customer workspace
</x-filament::button>
</div>
@endif
</x-filament::section>
</div>

View File

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantReviewResource;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
beforeEach(function (): void {
Storage::fake('exports');
});
it('smokes the customer review workspace handoff from tenant review detail', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(
tenant: $tenantPublished,
role: 'owner',
workspaceRole: 'manager',
);
$tenantWithoutPublished = Tenant::factory()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'No Published Tenant',
]);
createUserWithTenant(
tenant: $tenantWithoutPublished,
user: $user,
role: 'owner',
workspaceRole: 'manager',
);
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
$publishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenantPublished->getKey(),
'workspace_id' => (int) $tenantPublished->workspace_id,
'tenant_review_id' => (int) $publishedReview->getKey(),
'evidence_snapshot_id' => (int) $publishedSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/customer-review-workspace-smoke.zip',
'file_disk' => 'exports',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantPublished->workspace_id => (int) $tenantPublished->getKey(),
],
]);
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
->waitForText('Related context')
->assertSee('Open customer workspace')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Open customer workspace')
->waitForText('Customer-safe review workspace')
->assertSee('Clear filters')
->assertSee('Open latest review')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->click('Clear filters')
->waitForText('No published review available yet')
->assertSee('No published review available yet')
->click('Open latest review')
->waitForText('Outcome summary')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')
->assertDontSee('Export executive pack')
->assertDontSee('Archive review')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -5,14 +5,12 @@
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\RestoreRun;
use App\Models\User;
use App\Models\Workspace;
@ -242,47 +240,6 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
expect($headerCreate?->isVisible())->toBeTrue();
});
it('shows generate only in empty state when review packs table is empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateGenerate = getPlacementEmptyStateAction($component, 'generate_first');
expect($emptyStateGenerate)->not->toBeNull();
expect($emptyStateGenerate?->getLabel())->toBe('Generate first pack');
$headerGenerate = getHeaderAction($component, 'generate_pack');
expect($headerGenerate)->not->toBeNull();
expect($headerGenerate?->isVisible())->toBeFalse();
});
it('shows generate only in header when review packs table is not empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::test(ListReviewPacks::class)
->assertCountTableRecords(1);
$headerGenerate = getHeaderAction($component, 'generate_pack');
expect($headerGenerate)->not->toBeNull();
expect($headerGenerate?->isVisible())->toBeTrue();
expect($headerGenerate?->getLabel())->toBe('Generate Pack');
});
it('shows create only in empty state when tenants table is empty', function (): void {
$workspace = Workspace::factory()->create([
'archived_at' => now(),

View File

@ -1,111 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
/**
* @return array{0: Workspace, 1: User}
*/
function entitlementSettingsManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
it('saves entitlement plan profile and override pairs through the workspace settings page', function (): void {
[$workspace, $user] = entitlementSettingsManager();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful()
->assertSee('Workspace entitlements');
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.entitlements_plan_profile', null)
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null)
->assertSet('data.entitlements_review_pack_generation_override_value', null)
->assertSet('data.entitlements_review_pack_generation_override_reason', null)
->set('data.entitlements_plan_profile', 'starter')
->set('data.entitlements_managed_tenant_limit_override_value', 2)
->set('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
->set('data.entitlements_review_pack_generation_override_value', '0')
->set('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.entitlements_plan_profile', 'starter')
->assertSet('data.entitlements_managed_tenant_limit_override_value', 2)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
->assertSet('data.entitlements_review_pack_generation_override_value', '0')
->assertSet('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only');
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
expect($summary['plan_profile']['id'])->toBe('starter')
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
->toMatchArray([
'effective_value' => 2,
'source' => 'workspace_override',
'rationale' => 'Temporary support-approved exception',
'last_changed_by' => $user->name,
])
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED])
->toMatchArray([
'effective_value' => false,
'source' => 'workspace_override',
'rationale' => 'Workspace is temporarily limited to manual reporting only',
'last_changed_by' => $user->name,
]);
$component
->mountFormComponentAction('entitlements_managed_tenant_limit_override_value', 'reset_entitlements_managed_tenant_limit_override_value', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null);
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
expect($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
->toMatchArray([
'effective_value' => 1,
'source' => 'plan_profile_default',
'rationale' => 'Minimal allowance for early workspace access and low-volume operations.',
]);
});
it('requires an override reason when a workspace entitlement override value is set', function (): void {
[, $user] = entitlementSettingsManager();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.entitlements_managed_tenant_limit_override_value', 3)
->set('data.entitlements_managed_tenant_limit_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_managed_tenant_limit_override_reason']);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.entitlements_review_pack_generation_override_value', '0')
->set('data.entitlements_review_pack_generation_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
});

View File

@ -950,7 +950,6 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,

View File

@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\File;
it('prevents ai governance surfaces from declaring direct outbound or vendor-specific provider runtime code', function (): void {
$root = app_path();
$files = collect(File::allFiles($root))
->map(fn (\SplFileInfo $file): string => str_replace($root.'/', '', $file->getPathname()))
->filter(fn (string $relativePath): bool => str_starts_with($relativePath, 'Support/Ai/')
|| $relativePath === 'Support/ProductKnowledge/ContextualHelpResolver.php'
|| $relativePath === 'Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php')
->values();
$patterns = [
'outbound_http' => '/\bHttp::/',
'guzzle_client' => '/\bnew\s+Client\b/',
'curl_runtime' => '/\bcurl_/i',
'openai_vendor' => '/\bOpenAI\b/i',
'anthropic_vendor' => '/\bAnthropic\b/i',
'gemini_vendor' => '/\bGemini\b/i',
'openrouter_vendor' => '/\bOpenRouter\b/i',
'chat_completions_runtime' => '/\bChatCompletion\b/i',
];
$hits = [];
foreach ($files as $relativePath) {
$contents = file_get_contents($root.'/'.$relativePath);
if (! is_string($contents) || $contents === '') {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($patterns as $label => $pattern) {
foreach ($lines as $index => $line) {
if (preg_match($pattern, $line) === 1) {
$hits[] = $relativePath.':'.($index + 1).' ['.$label.'] '.trim($line);
}
}
}
}
expect($hits)->toBeEmpty("AI governance surfaces must stay vendor-neutral and must not perform outbound provider runtime calls directly:\n".implode("\n", $hits));
});

View File

@ -35,7 +35,6 @@ function spec195FormattedIssues(array $issues): string
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
@ -68,7 +67,6 @@ function spec195FormattedIssues(array $issues): string
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Controls::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
@ -78,7 +76,6 @@ function spec195FormattedIssues(array $issues): string
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
*/
function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array
{
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
if ($activeTenantCount > 0) {
Tenant::factory()->count($activeTenantCount)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
}
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection',
'is_default' => true,
'consent_status' => 'granted',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
],
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'bootstrap',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
if ($limitOverride !== null) {
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: $limitOverride,
);
if ($overrideReason !== null) {
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
value: $overrideReason,
);
}
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
]);
return compact('workspace', 'user', 'tenant', 'draft', 'component');
}
it('allows onboarding activation when the workspace is within its managed tenant limit', function (): void {
$context = readyOnboardingEntitlementContext(activeTenantCount: 0);
$context['component']->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
});
it('blocks onboarding activation with a business-state reason when the workspace is at limit', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 1,
limitOverride: 1,
overrideReason: 'Customer currently allows one active tenant',
);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$context['workspace'],
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision['is_blocked'])->toBeTrue();
$context['component']
->assertSee('Activation entitlement')
->assertSee('Blocked')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});
it('allows onboarding activation when a workspace override raises the limit above current usage', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 1,
limitOverride: 2,
overrideReason: 'Temporary support-approved exception',
);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$context['workspace'],
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision)
->toMatchArray([
'source' => 'workspace_override',
'effective_value' => 2,
'current_usage' => 1,
'is_blocked' => false,
'rationale' => 'Temporary support-approved exception',
]);
$context['component']->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
});

View File

@ -2,17 +2,14 @@
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -134,49 +131,3 @@ function seedRestoreAuthorizationContext(): array
->call('create')
->assertNotified('Restore execution paused');
});
it('forbids ai execution controls for platform users missing system panel access', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertForbidden();
});
it('forbids ai execution controls for platform users missing ops controls manage', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertForbidden();
});
it('shows ai execution controls only to platform users with the existing system control capabilities', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee('AI execution');
Livewire::actingAs($user, 'platform')
->test(Controls::class)
->assertActionVisible('pause_ai_execution')
->assertActionVisible('resume_ai_execution');
});

View File

@ -3,9 +3,7 @@
declare(strict_types=1);
use App\Models\ReviewPack;
use App\Models\AuditLog;
use App\Services\ReviewPackService;
use App\Support\Audit\AuditActionId;
use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@ -43,25 +41,13 @@ function createReadyPackWithFile(?array $packOverrides = []): array
it('downloads a ready pack via signed URL with correct headers', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'customer_review_workspace',
]);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$response = $this->actingAs($user)->get($signedUrl);
$response->assertOk();
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
$response->assertDownload();
$audit = AuditLog::query()
->where('action', AuditActionId::ReviewPackDownloaded->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('review_pack')
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});
// ─── Expired Signature → 403 ────────────────────────────────

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
beforeEach(function (): void {
Storage::fake('exports');
});
function seedEntitlementReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
{
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => ['required_count' => 1, 'granted_count' => 1],
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
'payload' => ['roles' => [['displayName' => 'Global Administrator']]],
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var EvidenceSnapshotService $service */
$service = app(EvidenceSnapshotService::class);
$payload = $service->buildSnapshotPayload($tenant);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness'],
'summary' => $payload['summary'],
'generated_at' => now(),
]);
foreach ($payload['items'] as $item) {
$snapshot->items()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'dimension_key' => $item['dimension_key'],
'state' => $item['state'],
'required' => $item['required'],
'source_kind' => $item['source_kind'],
'source_record_type' => $item['source_record_type'],
'source_record_id' => $item['source_record_id'],
'source_fingerprint' => $item['source_fingerprint'],
'measured_at' => $item['measured_at'],
'freshness_at' => $item['freshness_at'],
'summary_payload' => $item['summary_payload'],
'sort_order' => $item['sort_order'],
]);
}
return $snapshot;
}
function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, string $reason): void
{
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: $reason,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
});
it('blocks executive pack export before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEntitlementReviewPackSnapshot($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generateFromReview($review, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
});
it('shows the blocked reason on the review pack card and keeps existing pack downloads accessible', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is temporarily limited to manual reporting only')
->assertSee('Generate pack')
->call('generatePack', true, true)
->assertHasNoErrors();
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
$filePath = 'review-packs/entitlement-download-test.zip';
Storage::disk('exports')->put($filePath, 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => $filePath,
'file_disk' => 'exports',
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee('Download');
});

View File

@ -9,13 +9,11 @@
use App\Services\ReviewPackService;
use App\Support\Auth\UiTooltips;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
use Livewire\Features\SupportTesting\Testable;
uses(RefreshDatabase::class);
@ -23,17 +21,6 @@
Storage::fake('exports');
});
function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
// ─── Non-Member Access ───────────────────────────────────────
it('returns 404 for non-member on list page', function (): void {
@ -77,9 +64,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
'file_disk' => 'exports',
]);
// Note: download route uses signed middleware, not tenant-scoped RBAC.
// Any user with a valid signature can download. This is by design.
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$this->actingAs($user)->get($signedUrl)->assertNotFound();
$this->actingAs($user)->get($signedUrl)->assertOk();
});
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
@ -135,15 +124,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
->assertActionVisible('generate_pack')
->assertActionDisabled('generate_pack')
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
@ -152,12 +137,6 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);

View File

@ -13,19 +13,16 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\UiTooltips;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunLinks;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Livewire\Features\SupportTesting\Testable;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
@ -34,31 +31,6 @@
Storage::fake('exports');
});
function getReviewPackEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function getReviewPackHeaderAction(Testable $component, string $name): ?Action
{
$instance = $component->instance();
$instance->cacheInteractsWithHeaderActions();
foreach ($instance->getCachedHeaderActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
{
StoredReport::factory()->create([
@ -158,7 +130,8 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
'tenant_id' => (int) $otherTenant->getKey(),
]);
setTenantPanelContext($tenant);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
@ -177,112 +150,32 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
->assertSee('No review packs yet');
});
// ─── List Page Start CTA Placement ───────────────────────────
// ─── List Page Header Action ─────────────────────────────────
it('shows generate only in the empty state when no review packs exist', function (): void {
it('shows the generate_pack header action for a MANAGE user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
$headerAction = getReviewPackHeaderAction($component, 'generate_pack');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->getLabel())->toBe('Generate first pack')
->and($headerAction)->not->toBeNull()
->and($headerAction?->isVisible())->toBeFalse();
});
it('shows generate in the header once review packs exist', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack');
});
it('disables the generate_first action for a readonly user in the empty state', function (): void {
it('disables the generate_pack action for a readonly user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
});
it('disables review pack generation actions when the workspace entitlement blocks them', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: 'Workspace is temporarily limited to manual reporting only',
);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
expect(ReviewPackResource::reviewPackGenerationActionTooltip($tenant))
->toBe('Review pack generation is disabled by workspace override. Reason: Workspace is temporarily limited to manual reporting only');
$listPage = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($listPage, 'generate_first');
$headerAction = getReviewPackHeaderAction($listPage, 'generate_pack');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($headerAction)->not->toBeNull()
->and($headerAction?->isVisible())->toBeFalse();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
Livewire::actingAs($user)
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
->assertActionVisible('regenerate')
->assertActionDisabled('regenerate');
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack')
->assertActionDisabled('generate_pack')
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});
it('reuses an existing ready pack instead of starting a new run', function (): void {
@ -332,12 +225,6 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->failed()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
@ -349,7 +236,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
])
->assertNotified();
expect(ReviewPack::query()->count())->toBe(1);
expect(ReviewPack::query()->count())->toBe(0);
Queue::assertNothingPushed();
});

View File

@ -11,9 +11,6 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\ReasonTranslation\ReasonPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -51,13 +48,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create([
'type' => OperationRunType::TenantReviewCompose->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'started_at' => now()->subMinute(),
'completed_at' => now(),
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var EvidenceSnapshotService $service */
$service = app(EvidenceSnapshotService::class);

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns 404 for users outside the active workspace on the customer review workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 404 for workspace members that have no tenant review visibility in the active workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertNotFound();
});
it('allows entitled workspace members to access the customer review workspace', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertOk();
});
it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void {
$tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']);
[$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly');
$tenantDenied = Tenant::factory()->create([
'workspace_id' => (int) $tenantAllowed->workspace_id,
'name' => 'Denied Tenant',
]);
$otherOwner = User::factory()->create();
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey())
->assertNotFound();
});

View File

@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Models\AuditLog;
use App\Models\EvidenceSnapshot;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Support\Audit\AuditActionId;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\TenantReviewStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('renders a customer workspace link from tenant review detail context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('adds a customer workspace entry to evidence snapshot related context', function (): void {
$tenant = Tenant::factory()->create();
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'summary' => [],
'generated_at' => now(),
]);
$entry = collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))
->firstWhere('key', 'customer_review_workspace');
expect($entry)->not->toBeNull()
->and($entry['targetUrl'] ?? null)->toBe(CustomerReviewWorkspace::tenantPrefilterUrl($tenant));
});
it('renders a customer workspace link from review pack detail context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/customer-workspace-link.zip', 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/customer-workspace-link.zip',
'file_disk' => 'exports',
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('renders a customer workspace launch button on the tenant review pack widget', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/widget-customer-workspace.zip', 'PK-test');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/widget-customer-workspace.zip',
'file_disk' => 'exports',
]);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Customer workspace')
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('keeps the linked tenant review detail read-only for a readonly-capable actor', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertSee('Outcome summary')
->assertActionDoesNotExist('publish_review')
->assertActionDoesNotExist('refresh_review')
->assertActionDoesNotExist('create_next_review')
->assertActionDoesNotExist('export_executive_pack')
->assertActionHidden('archive_review');
$audit = AuditLog::query()
->where('action', AuditActionId::TenantReviewOpened->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('tenant_review')
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});

View File

@ -1,97 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows the ready review-pack action for the latest published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
'current_export_review_pack_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('Unavailable');
});
it('hides review and pack actions for tenants without a published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
'current_export_review_pack_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionHidden('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('No published review available yet');
});

View File

@ -1,222 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('lists only the latest published review per entitled tenant on the customer review workspace', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$tenantDenied = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Denied Tenant',
]);
$otherOwner = User::factory()->create();
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
$tenantASnapshot = seedTenantReviewEvidence($tenantA);
$tenantBSnapshot = seedTenantReviewEvidence($tenantB);
$tenantDeniedSnapshot = seedTenantReviewEvidence($tenantDenied);
$olderPublishedReview = composeTenantReviewForTest($tenantA, $user, $tenantASnapshot);
$olderPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subDays(3),
'published_at' => now()->subDays(3),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$newerInternalReview = $olderPublishedReview->replicate();
$newerInternalReview->forceFill([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
'status' => TenantReviewStatus::Ready->value,
'generated_at' => now()->subDay(),
'published_at' => null,
'published_by_user_id' => null,
])->save();
$latestPublishedReview = $olderPublishedReview->replicate();
$latestPublishedReview->forceFill([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
'status' => TenantReviewStatus::Published->value,
'generated_at' => now(),
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$betaPublishedReview = composeTenantReviewForTest($tenantB, $user, $tenantBSnapshot);
$betaPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subHours(2),
'published_at' => now()->subHours(2),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$deniedPublishedReview = composeTenantReviewForTest($tenantDenied, $otherOwner, $tenantDeniedSnapshot);
$deniedPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subHours(3),
'published_at' => now()->subHours(3),
'published_by_user_id' => (int) $otherOwner->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantDenied->fresh()])
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $betaPublishedReview->fresh()], $tenantB), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $olderPublishedReview->fresh()], $tenantA), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $newerInternalReview->fresh()], $tenantA), false)
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')
->assertDontSee('Regenerate')
->assertDontSee('Expire snapshot')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $deniedPublishedReview->fresh()], $tenantDenied), false);
});
it('shows entitled tenants without a published review as calm absence rows', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(tenant: $tenantPublished, role: 'readonly');
$tenantWithoutPublished = Tenant::factory()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'No Published Tenant',
]);
createUserWithTenant(tenant: $tenantWithoutPublished, user: $user, role: 'readonly');
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
$publishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subHour(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantPublished->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()])
->assertSee('No published review')
->assertSee('No published review available yet')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenantWithoutPublished), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false);
});
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$snapshotA = seedTenantReviewEvidence($tenantA);
$snapshotB = seedTenantReviewEvidence($tenantB);
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
$reviewA->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subDay(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
$reviewB->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->filterTable('tenant_id', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantA->fresh()]);
});
it('prefilters the customer review workspace from an explicit tenant query parameter and accepts external tenant identifiers', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$snapshotA = seedTenantReviewEvidence($tenantA);
$snapshotB = seedTenantReviewEvidence($tenantB);
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
$reviewA->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
$reviewB->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subDay(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->filterTable('tenant_id', (string) $tenantA->getKey())
->assertCanSeeTableRecords([$tenantA->fresh()])
->assertCanNotSeeTableRecords([$tenantB->fresh()]);
});

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Settings\SettingsResolver;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
/**
* @return array{0: Workspace, 1: User}
*/
function workspaceAiPolicyManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
it('renders the workspace ai policy section and lets managers save and reset the ai posture', function (): void {
[$workspace, $user] = workspaceAiPolicyManager();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful()
->assertSee('Workspace AI policy')
->assertSee('Disabled')
->assertSee('Private only')
->assertSee('Approved use cases')
->assertSee('Blocked data classifications');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('disabled');
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', null)
->set('data.ai_policy_mode', 'private_only')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', 'private_only');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('private_only');
$component
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', null);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('disabled');
});

View File

@ -79,76 +79,3 @@
->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
->and(data_get($audit?->metadata, 'after_value'))->toBe(30);
});
it('writes a workspace-scoped audit entry when ai policy mode is updated', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'ai',
key: 'policy_mode',
value: 'private_only',
);
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
->and($audit?->tenant_id)->toBeNull()
->and($audit?->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
->and(data_get($audit?->metadata, 'before_value'))->toBeNull()
->and(data_get($audit?->metadata, 'after_value'))->toBe('private_only');
});
it('writes a workspace-scoped audit entry when ai policy mode is reset', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'ai',
key: 'policy_mode',
value: 'private_only',
);
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'ai',
key: 'policy_mode',
);
$audit = AuditLog::query()
->where('action', AuditActionId::WorkspaceSettingReset->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
->and($audit?->tenant_id)->toBeNull()
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
->and(data_get($audit?->metadata, 'before_value'))->toBe('private_only')
->and(data_get($audit?->metadata, 'after_value'))->toBe('disabled');
});

View File

@ -44,7 +44,6 @@ function workspaceManagerUser(): array
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', null)
->assertSet('data.backup_retention_keep_last_default', null)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', [])
@ -59,7 +58,6 @@ function workspaceManagerUser(): array
->assertSet('data.findings_sla_low', null)
->assertSet('data.operations_operation_run_retention_days', null)
->assertSet('data.operations_stuck_run_threshold_minutes', null)
->set('data.ai_policy_mode', 'private_only')
->set('data.backup_retention_keep_last_default', 55)
->set('data.backup_retention_min_floor', 12)
->set('data.drift_severity_mapping', ['drift' => 'critical'])
@ -76,7 +74,6 @@ function workspaceManagerUser(): array
->set('data.operations_stuck_run_threshold_minutes', 60)
->callAction('save')
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', 'private_only')
->assertSet('data.backup_retention_keep_last_default', 55)
->assertSet('data.backup_retention_min_floor', 12)
->assertSet('data.baseline_severity_missing_policy', 'critical')
@ -100,9 +97,6 @@ function workspaceManagerUser(): array
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
->toBe(55);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('private_only');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
->toBe(12);
@ -148,18 +142,6 @@ function workspaceManagerUser(): array
->where('key', 'retention_keep_last_default')
->exists())->toBeFalse();
$component
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', null);
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'ai')
->where('key', 'policy_mode')
->exists())->toBeFalse();
$component
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
->callMountedFormComponentAction()

View File

@ -5,7 +5,6 @@
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
@ -13,14 +12,6 @@
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceSetting::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'ai',
'key' => 'policy_mode',
'value' => 'private_only',
'updated_by_user_id' => null,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)

View File

@ -30,14 +30,6 @@
'updated_by_user_id' => null,
]);
WorkspaceSetting::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'ai',
'key' => 'policy_mode',
'value' => 'private_only',
'updated_by_user_id' => null,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
@ -46,7 +38,6 @@
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', 'private_only')
->assertSet('data.backup_retention_keep_last_default', 27)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', [])
@ -65,8 +56,6 @@
->assertActionDisabled('save')
->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
->assertFormComponentActionVisible('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->assertFormComponentActionDisabled('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
@ -86,11 +75,6 @@
->call('save')
->assertStatus(403);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->call('resetSetting', 'ai_policy_mode')
->assertStatus(403);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->call('resetSetting', 'backup_retention_keep_last_default')
@ -104,12 +88,5 @@
->where('key', 'retention_keep_last_default')
->first();
$aiSetting = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'ai')
->where('key', 'policy_mode')
->first();
expect($setting)->not->toBeNull()
->and($aiSetting)->not->toBeNull();
expect($setting)->not->toBeNull();
});

View File

@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
function makeAiControlsManager(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
}
it('pauses and resumes ai execution through the global-only controls card', function (): void {
$workspaceA = Workspace::factory()->create(['name' => 'Acme']);
$workspaceB = Workspace::factory()->create(['name' => 'Bravo']);
Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]);
Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]);
$user = makeAiControlsManager();
$this->actingAs($user, 'platform');
$this->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee("mountAction('pause_ai_execution')", escape: false);
$component = Livewire::test(Controls::class)
->assertActionExists('pause_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('resume_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('view_history_ai_execution', fn (Action $action): bool => $action->getLabel() === 'View AI execution history');
$summary = $component->instance()->controlSummary('ai.execution');
$preview = $component->instance()->scopeImpactPreview('ai.execution', 'global', null);
expect($summary['label'])->toBe('AI execution')
->and($summary['supported_scopes'])->toBe(['global'])
->and($summary['effective_state'])->toBe('enabled')
->and($preview['summary'])->toContain('AI execution')
->and($preview['workspace_count'])->toBe(2)
->and($preview['tenant_count'])->toBe(3);
$component
->callAction('pause_ai_execution', data: [
'scope_type' => 'global',
'reason_text' => 'Paused for AI rollout review.',
'expires_at' => now()->addDay()->toDateTimeString(),
])
->assertNotified('AI execution paused');
$activation = OperationalControlActivation::query()
->forControl('ai.execution')
->forGlobalScope()
->first();
expect($activation)->not->toBeNull()
->and($activation?->reason_text)->toBe('Paused for AI rollout review.');
$pausedSummary = $component->instance()->controlSummary('ai.execution');
expect($pausedSummary['effective_state'])->toBe('paused')
->and($pausedSummary['state_label'])->toBe('Paused globally');
$component
->callAction('resume_ai_execution', data: [
'scope_type' => 'global',
])
->assertNotified('AI execution resumed');
expect(OperationalControlActivation::query()
->forControl('ai.execution')
->forGlobalScope()
->count())->toBe(0);
$audits = AuditLog::query()
->whereIn('action', [
AuditActionId::OperationalControlPaused->value,
AuditActionId::OperationalControlResumed->value,
])
->where('metadata->control_key', 'ai.execution')
->orderBy('id')
->get();
expect($audits)->toHaveCount(2)
->and($audits[0]->workspace_id)->toBeNull()
->and($audits[1]->workspace_id)->toBeNull();
});

View File

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
$manager = User::factory()->create(['name' => 'Workspace Manager']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $manager->getKey(),
'role' => 'manager',
]);
Tenant::factory()->count(2)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'plan_profile',
value: 'starter',
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
value: 2,
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
value: 'Pilot workspace',
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: 'Escalation only',
);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Workspace entitlements')
->assertSee('Starter')
->assertSee('Pilot workspace')
->assertSee('Escalation only')
->assertSee('workspace override')
->assertDontSee('Save');
});

View File

@ -1,155 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function entitledWorkspaceManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
return [$workspace, $user];
}
it('falls back to the default plan profile when a workspace has no entitlement settings', function (): void {
[$workspace] = entitledWorkspaceManager();
$resolver = app(WorkspaceEntitlementResolver::class);
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
expect($managedTenantLimit)
->toMatchArray([
'plan_profile_id' => 'standard',
'key' => WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => 25,
'source' => 'plan_profile_default',
'current_usage' => 0,
'remaining_capacity' => 25,
'is_blocked' => false,
])
->and($managedTenantLimit['rationale'])->toBe('Balanced defaults for most managed workspaces.')
->and($managedTenantLimit['last_changed_at'])->toBeNull()
->and($managedTenantLimit['last_changed_by'])->toBeNull();
expect($reviewPackGeneration)
->toMatchArray([
'plan_profile_id' => 'standard',
'key' => WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => true,
'source' => 'plan_profile_default',
'is_blocked' => false,
]);
});
it('applies the selected plan profile defaults when no explicit override is set', function (): void {
[$workspace, $user] = entitledWorkspaceManager();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
value: 'starter',
);
$resolver = app(WorkspaceEntitlementResolver::class);
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
expect($managedTenantLimit)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => 1,
'source' => 'plan_profile_default',
'current_usage' => 0,
'remaining_capacity' => 1,
'is_blocked' => false,
])
->and($managedTenantLimit['last_changed_by'])->toBe($user->name)
->and($managedTenantLimit['last_changed_at'])->not->toBeNull();
expect($reviewPackGeneration)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => false,
'source' => 'plan_profile_default',
'is_blocked' => true,
])
->and($reviewPackGeneration['block_reason'])->toContain('Starter');
});
it('applies workspace override values, rationale, and usage-aware blocking', function (): void {
[$workspace, $user] = entitledWorkspaceManager();
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
value: 'starter',
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 2,
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
value: 'Temporary support-approved exception',
);
Tenant::factory()->count(2)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => 2,
'source' => 'workspace_override',
'rationale' => 'Temporary support-approved exception',
'current_usage' => 2,
'remaining_capacity' => 0,
'is_blocked' => true,
'last_changed_by' => $user->name,
])
->and($decision['last_changed_at'])->not->toBeNull()
->and($decision['block_reason'])->toContain('workspace override')
->and($decision['block_reason'])->toContain('Temporary support-approved exception');
});

View File

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
it('exposes a bounded profile catalog with exactly one default profile', function (): void {
$catalog = app(WorkspacePlanProfileCatalog::class);
$profiles = $catalog->all();
expect($profiles)
->toHaveCount(3)
->and(collect($profiles)->where('is_default', true))->toHaveCount(1)
->and(WorkspacePlanProfileCatalog::defaultProfileId())->toBe('standard')
->and($catalog->default()['label'])->toBe('Standard');
});
it('resolves known profiles and falls back to the default for unknown identifiers', function (): void {
$catalog = app(WorkspacePlanProfileCatalog::class);
expect($catalog->resolve('starter'))
->toMatchArray([
'id' => 'starter',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
])
->and($catalog->resolve('missing-profile')['id'])->toBe('standard')
->and($catalog->optionLabels())
->toMatchArray([
'starter' => 'Starter',
'standard' => 'Standard',
'scale' => 'Scale',
]);
});

View File

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('exposes only the approved product knowledge source input for ai answer drafts', function (): void {
$source = app(ContextualHelpResolver::class)->aiProductKnowledgeAnswerDraftSource();
expect($source)->toMatchArray([
'use_case_key' => 'product_knowledge.answer_draft',
'source_family' => 'product_knowledge',
'data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
])
->and($source['topics'])->not->toBeEmpty()
->and($source['operational_metadata'])->toHaveKeys(['version', 'topic_count'])
->and($source)->not->toHaveKeys(['tenant', 'tenant_id', 'workspace', 'workspace_id']);
});
it('exposes only the approved redacted support summary input for ai diagnostic drafts', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$source = app(SupportDiagnosticBundleBuilder::class)->aiSupportDiagnosticsSummaryDraftSource($tenant);
expect($source)->toMatchArray([
'use_case_key' => 'support_diagnostics.summary_draft',
'source_family' => 'support_diagnostics',
'data_classifications' => [
AiDataClassification::RedactedSupportSummary->value,
],
])
->and($source['summary'])->toHaveKeys([
'headline',
'dominant_issue',
'freshness_state',
'redaction_note',
'generated_from',
])
->and(data_get($source, 'redaction.mode'))->toBe('default_redacted')
->and($source)->not->toHaveKeys(['sections', 'context', 'tenant', 'workspace', 'operation_run']);
});

View File

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\Ai\AiDecisionAuditMetadataFactory;
use App\Support\Ai\AiDecisionReasonCode;
use App\Support\Ai\AiExecutionDecision;
use App\Support\Ai\AiExecutionRequest;
use App\Support\Ai\AiProviderClass;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds bounded decision metadata without raw prompt, source, provider, or output payloads', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
$request = new AiExecutionRequest(
workspace: $workspace,
tenant: $tenant,
actor: null,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
sourceFamily: 'support_diagnostics',
callerSurface: 'support_diagnostics',
contextFingerprint: 'support_diagnostics:summary:v1',
);
$decision = new AiExecutionDecision(
outcome: 'blocked',
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
workspaceAiPolicyMode: 'private_only',
matchedOperationalControlScope: null,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
sourceFamily: 'support_diagnostics',
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
$metadata = app(AiDecisionAuditMetadataFactory::class)->make($request, $decision);
expect($metadata)->toMatchArray([
'use_case_key' => 'support_diagnostics.summary_draft',
'decision_outcome' => 'blocked',
'decision_reason' => AiDecisionReasonCode::DataClassificationBlocked->value,
'workspace_ai_policy_mode' => 'private_only',
'requested_provider_class' => 'local_private',
'data_classifications' => ['redacted_support_summary'],
'source_family' => 'support_diagnostics',
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'context_fingerprint' => 'support_diagnostics:summary:v1',
])
->and($metadata)->not->toHaveKeys([
'prompt_text',
'source_payload',
'provider_payload',
'output_text',
]);
});

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\Ai\AiDataClassification;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
it('locks the first slice to the two approved private-only use cases', function (): void {
$definitions = app(AiUseCaseCatalog::class)->all();
expect($definitions)->toHaveCount(2)
->and($definitions[0])->toMatchArray([
'key' => 'product_knowledge.answer_draft',
'label' => 'Product knowledge answer draft',
'future_consumer' => 'ContextualHelpResolver',
'source_family' => 'product_knowledge',
'tenant_context_permitted' => false,
])
->and($definitions[0]['allowed_provider_classes'])->toBe(['local_private'])
->and($definitions[0]['allowed_data_classifications'])->toBe([
'product_knowledge',
'operational_metadata',
])
->and($definitions[1])->toMatchArray([
'key' => 'support_diagnostics.summary_draft',
'label' => 'Support diagnostics summary draft',
'future_consumer' => 'SupportDiagnosticBundleBuilder',
'source_family' => 'support_diagnostics',
'tenant_context_permitted' => true,
])
->and($definitions[1]['allowed_provider_classes'])->toBe(['local_private'])
->and($definitions[1]['allowed_data_classifications'])->toBe([
'redacted_support_summary',
]);
});
it('derives provider and blocked-data summaries from the catalog for the workspace policy surface', function (): void {
$catalog = app(AiUseCaseCatalog::class);
expect($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::Disabled))->toBe([])
->and($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::PrivateOnly))->toBe(['Local private'])
->and($catalog->blockedDataClassificationLabels())->toBe([
AiDataClassification::PersonalData->label(),
AiDataClassification::CustomerConfidential->label(),
AiDataClassification::RawProviderPayload->label(),
]);
});

View File

@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSetting;
use App\Support\Ai\AiDataClassification;
use App\Support\Ai\AiDecisionReasonCode;
use App\Support\Ai\AiExecutionRequest;
use App\Support\Ai\AiProviderClass;
use App\Support\Ai\GovernedAiExecutionBoundary;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function aiPolicyWorkspace(string $policyMode = 'private_only'): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'ai',
'key' => 'policy_mode',
'value' => $policyMode,
'updated_by_user_id' => (int) $user->getKey(),
]);
return [$workspace, $user];
}
it('allows approved local-private support-diagnostics requests and writes bounded audit metadata', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: $tenant,
actor: $user,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
sourceFamily: 'support_diagnostics',
callerSurface: 'support_diagnostics',
contextFingerprint: 'support_diagnostics:summary:v1',
)));
expect($decision->isAllowed())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::Allowed)
->and($decision->workspaceAiPolicyMode)->toBe('private_only')
->and($decision->matchedOperationalControlScope)->toBeNull();
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit?->action)->toBe(AuditActionId::AiExecutionDecisionEvaluated->value)
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and(data_get($audit?->metadata, 'decision_outcome'))->toBe('allowed')
->and(data_get($audit?->metadata, 'decision_reason'))->toBe(AiDecisionReasonCode::Allowed->value)
->and(data_get($audit?->metadata, 'use_case_key'))->toBe('support_diagnostics.summary_draft')
->and(data_get($audit?->metadata, 'requested_provider_class'))->toBe('local_private')
->and(data_get($audit?->metadata, 'data_classifications'))->toBe(['redacted_support_summary'])
->and(data_get($audit?->metadata, 'context_fingerprint'))->toBe('support_diagnostics:summary:v1')
->and(data_get($audit?->metadata, 'prompt_text'))->toBeNull()
->and(data_get($audit?->metadata, 'output_text'))->toBeNull();
});
it('blocks external-public provider classes before any provider resolution', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: null,
actor: $user,
useCaseKey: 'product_knowledge.answer_draft',
requestedProviderClass: AiProviderClass::ExternalPublic->value,
dataClassifications: [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
sourceFamily: 'product_knowledge',
callerSurface: 'product_knowledge',
contextFingerprint: 'product_knowledge:answer:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::ProviderClassBlocked)
->and($decision->matchedOperationalControlScope)->toBeNull();
});
it('blocks disallowed data classifications before any provider resolution', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: $tenant,
actor: $user,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RawProviderPayload->value],
sourceFamily: 'support_diagnostics',
callerSurface: 'support_diagnostics',
contextFingerprint: 'support_diagnostics:raw:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::DataClassificationBlocked);
});
it('blocks unregistered use cases', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: null,
actor: $user,
useCaseKey: 'customer_email.reply',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::ProductKnowledge->value],
sourceFamily: 'product_knowledge',
callerSurface: 'product_knowledge',
contextFingerprint: 'customer_email:reply:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::UnregisteredUseCase);
});
it('lets the ai execution operational control override an otherwise valid request', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'ai.execution',
'reason_text' => 'Paused for AI rollout review.',
]);
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: null,
actor: $user,
useCaseKey: 'product_knowledge.answer_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
sourceFamily: 'product_knowledge',
callerSurface: 'product_knowledge',
contextFingerprint: 'product_knowledge:answer:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::OperationalControlPaused)
->and($decision->matchedOperationalControlScope)->toBe('global');
});

View File

@ -7,18 +7,12 @@
it('exposes only active runtime controls in the bounded control catalog', function (): void {
$catalog = app(OperationalControlCatalog::class);
expect($catalog->keys())->toBe(['restore.execute', 'ai.execution'])
expect($catalog->keys())->toBe(['restore.execute'])
->and($catalog->definition('restore.execute'))->toMatchArray([
'key' => 'restore.execute',
'label' => 'Restore execution',
'supported_scopes' => ['global', 'workspace'],
'operation_types' => ['restore.execute'],
])
->and($catalog->definition('ai.execution'))->toMatchArray([
'key' => 'ai.execution',
'label' => 'AI execution',
'supported_scopes' => ['global'],
'operation_types' => ['ai.execution'],
]);
});

View File

@ -1,273 +0,0 @@
# TenantPilot Implementation Ledger
## Purpose
Dieses Dokument beschreibt den aktuellen repo-basierten Implementierungsstand von TenantPilot. Es ergaenzt `roadmap.md` und `spec-candidates.md`, ersetzt sie aber nicht.
Bewertungsregeln fuer dieses Ledger:
- Repo-basiert only: Aussagen zaehlen nur, wenn Code, Datenmodell, Workflow, UI-Adoption oder Test-Artefakte im Repo belastbar darauf hinweisen.
- Keine Roadmap- oder Spec-Absicht ohne Repo-Evidence.
- `sellable` wird nur dort verwendet, wo UI, Workflow, Datenmodell, RBAC/Audit und passende Test-Artefakte plausibel zusammenpassen.
- Backend-only bleibt `foundation-only`.
- UI-only gilt nicht als fertig.
- Wenn Tests unten als vorhanden markiert sind, bedeutet das: passende Test-Dateien existieren im Repo. Sie wurden fuer dieses Ledger nicht ausgefuehrt.
## Current Product Position
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig.
## Status Model
- `planned`: nur in Roadmap oder Kandidatenliste, ohne belastbare Repo-Evidence
- `specified`: als Spec oder Draft angelegt, aber nicht repo-verifiziert umgesetzt
- `implemented_partial`: Teilumsetzung vorhanden, aber noch nicht als fertig bewertbar
- `implemented_backend`: belastbare Backend- oder Modelllogik vorhanden, aber keine ausreichende UI-Adoption
- `implemented_ui`: sichtbare UI vorhanden, aber Workflow- oder Backend-Proof ist noch zu schwach
- `implemented_verified`: Code, Modell, Workflow und Test-Artefakte sind plausibel vorhanden
- `adopted`: implementiert und bereits in zentrale Produktoberflaechen oder Kernablaeufe uebernommen
- `deferred`: bewusst verschoben
- `obsolete`: durch neuere Repo-Realitaet oder andere Implementierung ueberholt
Evidence-Level im Dokument:
- `none`: keine belastbare Repo-Evidence
- `weak`: duenne Code- oder Doc-Spur, aber kein belastbarer Gesamtworkflow
- `medium`: mehrere Repo-Signale, aber noch nicht durchgaengig
- `strong`: Datenmodell, Workflow, UI- oder Test-Spur greifen konsistent ineinander
## Roadmap Coverage Summary
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|---|---|---:|---|---|---|---|
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. |
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. |
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. |
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. |
| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. |
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
## Implemented Capabilities
| Capability | Status | Backend | UI | Tests | RBAC/Audit | Sellable | Evidence |
|---|---|---|---|---|---|---|---|
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
| Entra admin roles reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`; `tests/Feature/EntraAdminRoles/*` |
| Stored reports substrate | implemented_verified | yes | partial | repo tests, not run | partial | foundation-only | `app/Models/StoredReport.php`; `tests/Feature/PermissionPosture/StoredReportModelTest.php`; `tests/Feature/EntraAdminRoles/StoredReportFingerprintTest.php` |
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
| Workspace entitlements | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/Entitlements/WorkspaceEntitlementResolver.php`; `tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` |
| Capability-first RBAC | adopted | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/Auth/CapabilityResolver.php`; `app/Services/Auth/RoleCapabilityMap.php`; many `tests/Feature/Rbac/*` |
| Audit log foundation | adopted | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/AuditLog.php`; `app/Services/Audit/WorkspaceAuditLogger.php`; many audit-focused feature tests |
| Canonical control catalog | implemented_verified | yes | partial | repo tests, not run | partial | foundation-only | `app/Support/Governance/Controls/CanonicalControlCatalog.php`; `config/canonical_controls.php`; `tests/Unit/Governance/*` |
| Portfolio triage continuity | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/PortfolioTriage/TenantTriageReviewService.php`; `app/Support/PortfolioTriage/*`; `tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` |
## Foundation-Only Capabilities
- OperationRun truth and canonical operation typing: starke Execution-Foundation, aber kein eigenstaendiger Kundennutzen-Surface.
- Audit log foundation: breit genutzt und wichtig fuer Governance, aber allein nicht verkaufbar.
- Capability-first RBAC: belastbar und testnah, bleibt aber Enablement-Layer.
- Workspace entitlements: reale Gate- und Override-Logik, aber noch keine volle Commercial Lifecycle Story.
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
## Partial Capabilities
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer.
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
- Product knowledge rollout: Help-Katalog und Resolver sind real, aber noch nicht breit genug adoptiert fuer "fertig".
## Planned But Not Implemented
- Platform Localization v1
- Private AI Execution & Usage Governance Foundation
- Human-in-the-Loop Autonomous Governance
- Standardization & Policy Quality / Intune Linting
- PSA / Ticketing Handoff
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1
- Later compliance overlays beyond the current control/evidence foundation
## Release Readiness
| Release / Theme | Readiness | Notes |
|---|---|---|
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. |
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. |
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
## Commercial Readiness
### Demo-ready
- Baseline compare and drift walkthroughs
- Review pack generation and export
- Provider health, onboarding readiness and required permissions
- Support diagnostics
- Permission posture and Entra admin roles reporting
### Almost sellable
- Review-driven governance workflow around tenant reviews and review packs
- Baseline drift and restore governance
- Alerting and run visibility for governance operations
- Support requests with contextual diagnostics
- Provider readiness and permission posture reporting
### Foundation-only
- OperationRun truth layer
- Audit foundation
- Capability-first RBAC
- Workspace entitlements
- Canonical control catalog
- Stored reports substrate
- Evidence snapshot substrate
- Product telemetry
- Customer health scoring
- Operational controls
- Portfolio triage continuity
### Not sellable yet
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1
- Localization v1
- Private AI Execution Governance Foundation
- External Support Desk / PSA Handoff
- Compliance Light product layer
## Open Gaps & Blockers
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|---|---|---|---|---|
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment |
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
## Recommended Next Specs
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
## Roadmap Drift Notes
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented.
- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace.
## Evidence Sources
Wichtigste Strategie- und Scope-Quellen:
- `docs/product/roadmap.md`
- `docs/product/spec-candidates.md`
Wichtige Plattform- und UI-Anker:
- `apps/platform/bootstrap/providers.php`
- `apps/platform/app/Providers/Filament/AdminPanelProvider.php`
- `apps/platform/app/Providers/Filament/SystemPanelProvider.php`
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/app/Filament/System/Pages/Dashboard.php`
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
Wichtige Models:
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Models/Finding.php`
- `apps/platform/app/Models/FindingException.php`
- `apps/platform/app/Models/BaselineProfile.php`
- `apps/platform/app/Models/BaselineSnapshot.php`
- `apps/platform/app/Models/EvidenceSnapshot.php`
- `apps/platform/app/Models/TenantReview.php`
- `apps/platform/app/Models/ReviewPack.php`
- `apps/platform/app/Models/StoredReport.php`
- `apps/platform/app/Models/SupportRequest.php`
- `apps/platform/app/Models/ProductUsageEvent.php`
- `apps/platform/app/Models/OperationalControlActivation.php`
- `apps/platform/app/Models/AuditLog.php`
Wichtige Services und Jobs:
- `apps/platform/app/Services/ReviewPackService.php`
- `apps/platform/app/Services/TenantReviews/TenantReviewService.php`
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- `apps/platform/app/Services/Alerts/AlertDispatchService.php`
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
- `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
Wichtige Test-Anker im Repo:
- `apps/platform/tests/Feature/ReviewPack/*`
- `apps/platform/tests/Feature/Evidence/*`
- `apps/platform/tests/Feature/PermissionPosture/*`
- `apps/platform/tests/Feature/EntraAdminRoles/*`
- `apps/platform/tests/Feature/SupportDiagnostics/*`
- `apps/platform/tests/Feature/SupportRequests/*`
- `apps/platform/tests/Feature/System/CustomerHealth/*`
- `apps/platform/tests/Feature/System/ProductTelemetry/*`
- `apps/platform/tests/Feature/System/OpsControls/*`
- `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
- `apps/platform/tests/Unit/Governance/*`
- `apps/platform/tests/Unit/Entitlements/*`
## Last Updated
2026-04-27 on branch `248-private-ai-policy-foundation`

View File

@ -104,52 +104,6 @@ ### Data minimization & safe logging
---
## Governance & Decision Model
### Decision-first surfaces (non-negotiable)
Every operator-facing surface must default to:
- Decision
- Reason
- Impact
- One primary next action
Diagnostics and evidence must be progressively disclosed.
### Surface layering (mandatory)
All operator surfaces must follow a strict layering model:
1. Decision layer (default-visible)
2. Diagnostic layer (expandable)
3. Evidence layer (deep, raw, or audit-level)
No surface may start at diagnostic or raw data level.
### Multiple truth layers (explicit separation)
The platform separates:
- **Execution truth** (OperationRun)
- **Artifact truth** (Reports, Evidence)
- **Backup truth** (Snapshots)
- **Governance truth** (Findings, Exceptions)
These layers must never be conflated or implicitly derived from each other.
### Governance-first model
The system models governance explicitly as:
- **Expected state** (Baselines)
- **Observed state** (Inventory / Evidence)
- **Deviations** (Findings)
- **Decisions** (Exceptions / Risk acceptance)
All governance workflows must align with this model.
### Baselines as reference truth
Baselines define the expected state.
All comparisons, drift detection, and governance decisions must reference an explicit baseline.
Implicit or “last state vs current state” comparisons are forbidden.
### No false calmness (strict)
Missing, stale, or partial data must be explicitly visible.
The system must never present a "healthy" or "complete" state without sufficient evidence.
## UI & Information Architecture
### UI/UX constitution governs operator surfaces

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +0,0 @@
# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Business value and operator outcome stay explicit
- [x] The slice is tightly bounded to compare preview, promotion preflight, and portfolio launch continuity
- [x] Runtime-governance sections are present for an implementation-ready package
- [x] All mandatory sections are completed in `spec.md`, `plan.md`, and `tasks.md`
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Acceptance scenarios are defined for compare preview, read-only promotion preflight, and launch/return continuity
- [x] Edge cases are identified, including explicit rejection of same-tenant compare, cross-workspace attempts, lost entitlement, ambiguous identity, and stale target evidence
- [x] Scope is clearly bounded away from actual promotion execution, queues, persisted drafts, mapping automation, customer-facing compare, and multi-provider work
- [x] Dependencies, assumptions, risks, and follow-up candidates are identified
## Feature Readiness
- [x] The first slice is small enough for a bounded implementation loop
- [x] Concrete repo surfaces are named for compare reuse, portfolio launch, audit reuse, and likely new compare support files
- [x] Foundational work stays preparation-only and does not imply execution scope or new persistence
- [x] The tasks are ordered, testable, and grouped by user story
- [x] No unresolved product question blocks implementation once artifact analysis passes
## Governance Readiness
- [x] Workspace and tenant isolation rules are explicit, including `404` for non-members and out-of-scope tenants
- [x] The capability matrix is explicit: page access = `WORKSPACE_BASELINES_VIEW`, preview data = `TENANT_VIEW` on both tenants, preflight execution = `WORKSPACE_BASELINES_MANAGE`, and manage-denied members see a disabled preflight action with permission guidance
- [x] Promotion remains preflight-only, with no write execution, queue, or `OperationRun`
- [x] Audit remains bounded to promotion-preflight entry points with no new compare/promotion persistence truth
- [x] Livewire v4 and Filament v5 compliance, unchanged provider registration in `bootstrap/providers.php`, no new global-search resource, and no new asset strategy are explicit in the package
## Test Governance Review
- [x] Lane fit stays in focused `Unit` plus `Feature` validation only
- [x] Fixture and helper growth stays local to compare preview, preflight classification, and launch-context coverage
- [x] No browser, heavy-governance, or queue family is introduced implicitly
- [x] Minimal validation commands are explicit in the plan
- [x] The active feature PR close-out entry remains `Guardrail`
## Review Outcome
- [x] Review outcome class: `keep`
- [x] Workflow outcome: `keep`
- [x] Next command readiness: implementation prep is ready once artifact analysis is clear
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists.
- The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation.
- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets.

View File

@ -1,210 +1,24 @@
# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
# Implementation Plan: Cross-tenant Compare and Promotion
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Date**: 2026-01-07
**Spec**: `specs/043-cross-tenant-compare-and-promotion/spec.md`
## Summary
Refresh Spec 043 into a narrow, implementation-ready workflow that adds one canonical workspace-context compare page under `/admin`, one reusable compare preview builder, and one read-only promotion preflight action. The slice reuses existing baseline compare subject identity, portfolio-triage context continuity, capability resolvers, and workspace audit logging. It deliberately stops before actual promotion execution, queueing, or persisted promotion drafts.
Introduce read-only cross-tenant comparison views; optionally add promotion with strong safety gates.
Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected.
## Dependencies
## Technical Context
- Inventory core + UI (Specs 040041)
- Strong authorization model for multi-tenant access
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
**Testing**: Pest v4 `Unit` and `Feature` coverage only
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
**Project Type**: Web application (Laravel monolith with Filament pages)
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders
## Deliverables
## UI / Surface Guardrail Plan
- Tenant selection + comparison view
- Safe diff output and export
- (Optional) gated promotion workflow
- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context
- **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives
- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy
- **State layers in scope**: page, query state
- **Audience modes in scope**: operator-MSP only
- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces
- **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages
- **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary
- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly
- **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: Guardrail
## Risks
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**:
- `App\Filament\Pages\BaselineCompareLanding`
- `App\Filament\Pages\BaselineCompareMatrix`
- `App\Filament\Resources\TenantResource`
- `App\Filament\Resources\TenantResource\Pages\ListTenants`
- `App\Services\Baselines\BaselineCompareService`
- `App\Support\Baselines\BaselineCompareMatrixBuilder`
- `App\Support\Baselines\Compare\CompareStrategyRegistry`
- `App\Services\PortfolioTriage\TenantTriageReviewService`
- `App\Services\Audit\WorkspaceAuditLogger`
- `App\Support\Audit\AuditActionId`
- `App\Support\Navigation\CanonicalNavigationContext`
- **Shared abstractions reused**: capability resolvers, baseline compare strategy selection, canonical navigation context, existing audit recorder/logger path, and tenant-registry return-state conventions
- **New abstraction introduced? why?**: one narrow compare preview builder and one narrow promotion preflight service, because no existing service accepts source+target tenant scope and computes promotion readiness without execution
- **Why the existing abstraction was sufficient or insufficient**: tenant-level baseline compare is sufficient for subject identity, evidence posture, and drill-down semantics, but insufficient for dual-tenant scope and promotion-readiness reasoning
- **Bounded deviation / spread control**: no local compare sidecars on tenant pages; future callers must route through the canonical compare page and its services
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: compare preview and preflight remain synchronous and read-only
- **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**: Microsoft-first inventory subject identity and policy-type mapping remain inside existing baseline compare strategy selection and inventory data
- **Platform-core seams**: source/target tenant scope, compare preview contract, promotion preflight contract, operator-facing readiness vocabulary
- **Neutral platform terms / contracts preserved**: `source tenant`, `target tenant`, `governed subject`, `compare preview`, `promotion preflight`, and `blocked reason`
- **Retained provider-specific semantics and why**: existing policy-type and inventory semantics remain Microsoft-first because this repo still has one real provider domain; the compare page should not invent fake provider-neutral mapping logic above that seam
- **Bounded extraction or follow-up path**: follow-up-spec only if later provider domains become current-release truth
## Constitution Check
*GATE: Must pass before implementation preparation continues.*
- Inventory-first: PASS. Compare preview and preflight derive from existing inventory and policy-version truth rather than a new compare snapshot.
- Read/write separation: PASS. This slice stays read-only; no write execution is introduced.
- Graph contract path: PASS. No new Graph endpoint or direct provider call is added.
- Deterministic capabilities: PASS. Reuse existing capability registries such as `Capabilities::TENANT_VIEW`, `Capabilities::WORKSPACE_BASELINES_VIEW`, `Capabilities::WORKSPACE_BASELINES_MANAGE`, and existing tenant sync/manage seams.
- Workspace and tenant isolation: PASS. The compare page must resolve workspace membership first and source/target entitlement second, with `404` for inaccessible tenants.
- RBAC-UX plane separation: PASS. This slice lives only in `/admin`; no `/system` or cross-plane route is introduced.
- Destructive action discipline: PASS by non-use. The slice contains no destructive action.
- Global search: PASS. No new Resource or Global Search result is introduced.
- OperationRun / Ops-UX: PASS by non-use. Actual promotion execution is deferred.
- Data minimization: PASS. The compare page summarizes derived readiness and blocks; raw payloads stay on existing tenant/baseline pages.
- Test governance: PASS. Proof stays in `Unit` plus `Feature`; no browser or heavy-governance expansion is planned.
- Proportionality / no premature abstraction: PASS. One preview builder and one preflight service are justified by the dual-tenant workflow; no new persistence or framework layer is added.
- Persisted truth: PASS. No new compare or promotion table.
- Behavioral state: PASS. Readiness and blocked reasons remain derived, not persisted.
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing compare, navigation, and audit paths are extended rather than replaced.
- Provider boundary: PASS. Microsoft-shaped subject matching stays in existing strategy seams; the page contract stays platform-neutral.
- Filament/Laravel panel safety: PASS. Filament v5 remains on Livewire v4, no provider registration change beyond `bootstrap/providers.php`, and no new assets are planned.
**Gate evaluation**: PASS.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for the compare page, launch context, auth, and audit; `Unit` for compare preview matching and promotion-preflight classification
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: feature tests prove the Filament page and launch path while unit tests keep preview/preflight rules cheap and isolated. Browser or heavy-governance coverage is not required for the first read-only slice.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`
- **Fixture / helper / factory / seed / context cost risks**: reuse existing inventory, baseline compare, tenant registry, and portfolio-triage fixtures; avoid browser setup, queue fixtures, or seeded promotion history
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native-filament
- **Closing validation and reviewer handoff**: rerun the six focused commands above and confirm the slice remains read-only, deny-as-not-found-safe, and grounded on existing compare + portfolio seams
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: lane fit, hidden fixture growth, accidental write execution, accidental queue/runtime scope
- **Escalation path**: `document-in-feature` for contained lane drift, `reject-or-split` for any attempt to add execution scope
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: test upkeep remains feature-local; only actual promotion execution or multi-provider compare would warrant a separate follow-up spec
## Project Structure
### Documentation (this feature)
```text
specs/043-cross-tenant-compare-and-promotion/
├── checklists/
│ └── requirements.md
├── spec.md
├── plan.md
└── tasks.md
```
This refresh intentionally limits itself to the core preparation package plus `checklists/requirements.md`. No additional research/data-model/contracts artifact is required to make the narrowed slice implementation-ready.
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/Pages/
│ │ ├── BaselineCompareLanding.php
│ │ ├── BaselineCompareMatrix.php
│ │ └── [new canonical compare page]
│ ├── Filament/Resources/TenantResource.php
│ ├── Filament/Resources/TenantResource/Pages/ListTenants.php
│ ├── Models/
│ │ ├── InventoryItem.php
│ │ └── PolicyVersion.php
│ ├── Services/Audit/
│ │ └── WorkspaceAuditLogger.php
│ ├── Services/Baselines/
│ │ └── BaselineCompareService.php
│ ├── Services/PortfolioTriage/
│ │ └── TenantTriageReviewService.php
│ ├── Support/Audit/AuditActionId.php
│ ├── Support/Baselines/
│ │ ├── BaselineCompareMatrixBuilder.php
│ │ └── Compare/CompareStrategyRegistry.php
│ └── Support/PortfolioCompare/ or Services/PortfolioCompare/
└── tests/
├── Feature/PortfolioCompare/
└── Unit/Support/PortfolioCompare/
```
**Structure Decision**: keep implementation inside `apps/platform`, reuse existing compare and portfolio seams, and introduce at most one small `PortfolioCompare` support/service namespace for the new dual-tenant preview/preflight logic.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New compare preview builder | dual-tenant compare needs one place to translate existing inventory/baseline truth into a canonical preview contract | page-local mapping would duplicate compare logic and drift from existing baseline compare seams |
| New promotion preflight service | readiness reasoning must stay read-only and auditable before any execution path exists | bolting readiness rules into the page would make later reuse and testing brittle |
## Proportionality Review
- **Current operator problem**: portfolio operators still lack one bounded surface that answers whether a target tenant can follow a source tenant.
- **Existing structure is insufficient because**: existing baseline compare is tenant-vs-reference, not tenant-vs-tenant, and portfolio triage does not compute promotion readiness.
- **Narrowest correct implementation**: one canonical page plus one preview builder and one preflight service, no new table, no execution path.
- **Ownership cost created**: maintain a small preview/preflight contract and a focused test family.
- **Alternative intentionally rejected**: actual promotion execution, persisted promotion drafts, and local compare sidecars were rejected as premature.
- **Release truth**: current-release gap, not speculative platform work.
## Implementation Strategy
### Suggested MVP Scope
MVP = **US1 + US2 together**. A compare page without a promotion preflight leaves the core decision incomplete, and a preflight without a canonical compare page has no trustworthy operator context.
### Incremental Delivery
1. Reuse current compare, navigation, capability, and audit seams.
2. Deliver the canonical compare preview.
3. Add the read-only promotion preflight on top of the same page and services.
4. Add launch/return continuity from portfolio-triage and tenant-registry context.
5. Finish with narrow validation and formatting.
### Team Strategy
1. Settle the preview/preflight contracts first.
2. Parallelize unit tests for preview/preflight rules and feature tests for page/auth behavior.
3. Serialize merges around the canonical compare page and the shared `PortfolioCompare` service namespace so the page contract does not drift.
- Data leakage across tenants
- Over-scoping promotion beyond safe MVP

View File

@ -1,293 +1,59 @@
# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
# Feature Specification: Cross-tenant Compare and Promotion
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
**Feature Branch**: `feat/043-cross-tenant-compare-and-promotion`
**Created**: 2026-01-07
**Updated**: 2026-04-27
**Status**: Ready for implementation
**Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition.
**Status**: Draft
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
## Purpose
- **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision.
- **Today's failure**: Operators can see that tenants differ, but they still reconstruct cross-tenant decisions manually across tenant registry, baseline compare, and tenant detail surfaces. Promotion remains a roadmap phrase, not a bounded product workflow.
- **User-visible improvement**: An authorized workspace operator can select a source and target tenant, review a structured compare preview of governed subjects, and generate a read-only promotion preflight that shows what is ready, blocked, or requires manual mapping before any write path exists.
- **Smallest enterprise-capable version**: One canonical `/admin` compare surface, one compare preview builder, one read-only promotion preflight action, deep links back to existing tenant and baseline compare surfaces, and bounded audit metadata for preflight entry points. No actual promotion execution ships in this slice.
- **Explicit non-goals**: No cutover, no write execution, no queue or `OperationRun`, no automatic target remapping of groups/tags/named locations, no cross-workspace compare, no customer-facing compare workspace, no provider marketplace, and no new persisted promotion draft entity.
- **Permanent complexity imported**: One canonical compare page, one narrow compare scope contract, one preview/preflight builder pair, one small audit metadata shape, and focused unit plus feature coverage.
- **Why now**: The implementation ledger explicitly identifies cross-tenant compare and promotion as one of the remaining real product gaps. It is the missing bridge between portfolio visibility and portfolio action.
- **Why not local**: A local compare action on one tenant page would duplicate entitlement, matching, audit, and promotion-readiness logic and would not create a reusable, canonical workspace workflow.
- **Approval class**: Workflow Compression
- **Red flags triggered**: New page + new compare/preflight service pair. Defense: the slice stays read-only, introduces no new table, reuses existing baseline compare and portfolio triage seams, and defers actual execution.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
Enable safe cross-tenant comparison of inventory and, optionally, controlled promotion workflows.
## Spec Scope Fields *(mandatory)*
Comparison is read-only by default. Any write/promotion behavior must be explicitly gated, audited, and separately authorized.
- **Scope**: canonical-view
- **Primary Routes**:
- new canonical admin compare page under `/admin` for cross-tenant compare preview and promotion preflight
- existing `/admin/tenants` portfolio/registry surfaces as launch and return context
- existing tenant detail and baseline compare pages as secondary drill-down targets rather than duplicated local detail panes
- **Data Ownership**:
- compare preview and promotion preflight remain derived from existing tenant-owned inventory, policy-version, and baseline-compare truth
- no new compare snapshot, promotion draft, or mapping table is introduced in v1
- audit remains on the existing workspace audit log only
- **RBAC**:
- non-members or actors outside workspace scope receive `404`
- launch-action visibility requires established workspace context, `Capabilities::WORKSPACE_BASELINES_VIEW` on the workspace, and `Capabilities::TENANT_VIEW` on the launched tenant
- opening the compare page requires established workspace context and `Capabilities::WORKSPACE_BASELINES_VIEW` on the workspace
- loading preview data requires `Capabilities::TENANT_VIEW` on both source and target tenants
- executing promotion preflight requires the preview permissions plus `Capabilities::WORKSPACE_BASELINES_MANAGE` on the workspace
- for established members who can view compare but lack `Capabilities::WORKSPACE_BASELINES_MANAGE`, the preflight action remains visible but disabled with explicit permission help text; server-side attempts still return `403`
- the implementation must stay on existing capability registries instead of raw strings and must not introduce a new promotion capability family for this slice
## User Scenarios & Testing
For canonical-view specs, the spec MUST define:
### Scenario 1: Compare two tenants (read-only)
- Given the operator has access to Tenant A and Tenant B
- When they select two tenants and a set of policy types
- Then they can see differences in presence and key metadata
- **Default filter behavior when tenant-context is active**: if launched from the tenant registry or portfolio-triage context, prefill the launched tenant as the `target tenant`, leave the `source tenant` intentionally user-selected, and preserve a return context token.
- **Explicit entitlement checks preventing cross-tenant leakage**: the compare surface must validate workspace membership first, then validate both source and target tenant entitlement before any preview data loads. Any inaccessible tenant input is treated as not found.
### Scenario 2: Compare with a stable reference
- Given a reference selection scope
- When the operator runs comparison
- Then results are stable and reproducible for that scope
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
### Scenario 3: Promotion is explicitly gated (optional)
- Given promotion is enabled by policy
- When the operator initiates promotion
- Then the system requires explicit confirmation and records an audit event
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, compare/drill-down actions, audit metadata, and canonical workspace-context pages
- **Systems touched**: `ListTenants`, portfolio-triage state, `CanonicalNavigationContext`, `BaselineCompareLanding`, `BaselineCompareMatrix`, `BaselineCompareService`, `CompareStrategyRegistry`, `WorkspaceAuditLogger`, and `AuditActionId`
- **Existing pattern(s) to extend**: canonical `/admin` workspace-context pages, baseline compare preview patterns, portfolio-triage return-state patterns, and existing workspace audit metadata patterns
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `ActionSurfaceDeclaration`, `BaselineCompareService`, `BaselineCompareMatrixBuilder`, `CompareStrategyRegistry`, `TenantTriageReviewService`, and `WorkspaceAuditLogger`
- **Why the existing shared path is sufficient or insufficient**: existing tenant-level baseline compare surfaces already solve stable subject matching, result framing, and drill-down semantics, but they are insufficient for cross-tenant compare because they do not accept dual-tenant scope or produce a promotion-readiness preflight.
- **Allowed deviation and why**: none. The new surface should extend current compare and navigation patterns, not invent a parallel compare UX family.
- **Consistency impact**: source tenant, target tenant, compare preview, promotion preflight, blocked reason, and ready/manual mapping language must stay consistent across page copy, modal copy, audit prose, and deep links.
- **Review focus**: reviewers must block new local compare widgets or tenant-specific preflight sidecars that bypass the canonical compare page or its shared preview/preflight services.
## Functional Requirements
## 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`)*
- FR1: Support selecting two tenants within authorized scope.
- FR2: Provide read-only diff views based on inventory metadata and stable identifiers.
- FR3: Provide exportable comparison results.
- FR4: If promotion is included:
- require explicit enablement
- require explicit confirmation per operation
- record audit logs
- support dry-run/preview
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: compare preview and promotion preflight stay synchronous and read-only in v1
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Non-Functional Requirements
## 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**: compare subject identity, compare strategy reuse, promotion preflight reason vocabulary, and operator-facing compare terminology
- **Neutral platform terms preserved or introduced**: `source tenant`, `target tenant`, `governed subject`, `compare preview`, `promotion preflight`, `mapping gap`, and `blocked reason`
- **Provider-specific semantics retained and why**: Microsoft-first policy-type and inventory semantics remain inside existing compare strategy and inventory seams because the repo currently has one real provider domain. They should not leak deeper into the page contract than necessary.
- **Why this does not deepen provider coupling accidentally**: the page and services stay anchored on existing compare registries and inventory identifiers instead of inventing Microsoft-specific page contracts or raw Graph payload handling.
- **Follow-up path**: future multi-provider compare remains a separate follow-up spec if it ever becomes current-release truth.
## 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 |
|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | yes | Native Filament page plus shared compare primitives | compare preview, navigation, audit-backed preflight action | page, query state, compare summary, modal/action state | no | Reuses baseline compare language and drill-down patterns instead of a custom standalone shell |
| Tenant registry / portfolio launch action | yes | Native Filament action | navigation entry point, contextual launch | table state, query/deep-link state | no | Extends existing portfolio-triage return-state handling |
| Actual promotion execution surface | no | N/A | none | none | no | `N/A - explicitly deferred` |
## 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 |
|---|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | Primary Decision Surface | Operator decides whether the target tenant is ready for promotion planning or still blocked by scope and mapping gaps | source/target summary, ready/blocked/manual counts, top blockers, and next action | tenant drill-down, baseline compare drill-down, subject-level diagnostics | Primary because it is the first canonical workspace place where cross-tenant action becomes decidable | Moves from portfolio triage into compare and preflight without manual reconstruction | Replaces cross-page mental diffing with one bounded decision surface |
| Tenant registry / portfolio launch action | Secondary Context | Operator chooses when to leave the tenant registry for compare | current tenant context and preserved return state | compare details live on the compare page | Secondary because it launches the decision surface rather than hosting it | Keeps portfolio review flow intact | Reduces repeated tenant re-selection and filter loss |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker |
| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | Utility / Workspace Decision | Draft apply analysis | Generate promotion preflight or open drill-down evidence | explicit selectors plus focused compare/preflight panels | forbidden | drill-down links and secondary navigation stay below the summary/preflight sections | none in v1 | new canonical `/admin` compare route | same page with shareable query state | workspace context plus source/target tenant chips | Cross-tenant compare | whether the target is ready, blocked, or needs manual mapping | none |
| Tenant registry / portfolio launch action | List / Table / Launch Context | Launch context support | Open compare with current tenant prefilled | explicit action from tenant list or triage context | preserved existing row behavior | compare entry is a safe secondary action | none | `/admin/tenants` | compare route | current workspace and tenant | Tenant registry | why the action launches compare, not promotion | existing tenant registry action hierarchy remains valid |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none |
| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one narrow compare preview builder and one narrow promotion preflight service
- **New enum/state/reason family?**: no new persisted state family; readiness and blocked reasons remain derived from compare/preflight results
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: operators can identify tenants that need attention but cannot reach a trustworthy cross-tenant decision without manual reconstruction.
- **Existing structure is insufficient because**: existing tenant-level baseline compare pages and portfolio triage state do not support dual-tenant scope or promotion-readiness reasoning.
- **Narrowest correct implementation**: derive compare preview and promotion preflight from existing inventory/baseline truth, keep the page canonical and read-only, and audit only the preflight entry points.
- **Ownership cost**: maintain one compare page, one preview builder, one preflight service, and a handful of focused tests.
- **Alternative intentionally rejected**: actual promotion execution and persisted draft plans were rejected because they would add write risk, queue semantics, and new truth before the compare/preflight workflow is proven.
- **Release truth**: current-release workflow gap, not future-release platform speculation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical 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 preview matching and promotion-preflight classification without Filament overhead, while focused feature coverage proves page rendering, launch context, audit, and `404`/`403` semantics on the canonical compare surface.
- **New or expanded test families**: one focused `PortfolioCompare` feature family and one focused `Unit/Support/PortfolioCompare` family
- **Fixture / helper cost impact**: moderate; reuse existing tenant, workspace, inventory, baseline compare, and portfolio-triage fixtures instead of adding browser setup or queue scaffolding
- **Heavy-family visibility / justification**: none; do not widen this slice into browser or heavy-governance lanes by default
- **Special surface test profile**: standard-native-filament
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the page and launch actions; a small unit test set must prove preflight classification and no-write semantics
- **Reviewer handoff**: reviewers must confirm that the slice stays read-only, reuses baseline compare and portfolio seams, preserves deny-as-not-found semantics for inaccessible tenants, and does not smuggle in actual promotion execution
- **Budget / baseline / trend impact**: low increase in unit + feature only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`
## Scope Boundaries
### In Scope
- one canonical workspace-context compare page for source/target tenant selection
- read-only compare preview using stable governed-subject identity and existing compare strategy patterns
- one read-only promotion preflight action that classifies ready, blocked, and manual-mapping subjects
- workspace audit metadata for preflight entry points
- launch and return continuity from portfolio-triage/tenant-registry context
- deep links to existing tenant and baseline compare detail pages instead of duplicated proof surfaces
### Non-Goals
- actual promotion execution or target mutation
- queueing, retries, or `OperationRun`
- persisted compare snapshots or promotion draft tables
- automatic mapping writers for groups, scope tags, filters, named locations, or app references
- customer-facing review or compare surfaces
- cross-workspace compare
- multi-provider compare frameworks
## Assumptions
- existing inventory and baseline compare seams already provide enough stable subject identity to drive a first compare preview
- current portfolio-triage return-state patterns are sufficient for launch and back-navigation continuity
- a read-only preflight is valuable before any write path exists and can be audited without introducing a second persistence truth
## Risks
- some compare subjects may still need provider-specific mapping logic before they can produce a trustworthy readiness result
- target inventory freshness or missing evidence may block preflight more often than expected and needs explicit reasoning on the page
- a later implementation could try to add actual promotion execution inside this slice; that must be rejected as scope growth
## Follow-up Candidates
- Cross-tenant promotion execution with preview -> confirmation -> queued run -> verify
- Managed mapping workflows for named locations, assignments, groups, and filters
- Cross-tenant decision inbox integration after compare/preflight exists
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Compare two authorized tenants (Priority: P1)
As a workspace operator, I want to compare one source tenant to one target tenant from a canonical workspace surface so I can see where governed subjects match, differ, or are missing without reconstructing the answer manually.
**Why this priority**: This is the smallest valuable slice that turns portfolio visibility into a concrete operator decision surface.
**Independent Test**: Open the compare page with two authorized tenants, choose governed-subject filters, and verify that the compare preview shows reproducible ready/different/missing results and drill-down links.
**Acceptance Scenarios**:
1. **Given** an operator has access to both selected tenants, **When** they open the compare page and run the preview, **Then** they see a structured compare summary grouped by governed-subject state rather than a raw payload diff.
2. **Given** the same source and target selection, **When** the operator reloads or shares the preview URL, **Then** the compare state is reproducible for the same scoped selection.
3. **Given** the operator selects the same tenant as both source and target, **When** they try to run the preview, **Then** the page rejects the selection as invalid and does not produce compare or preflight output.
---
### User Story 2 - Generate a promotion preflight without writing (Priority: P1)
As a workspace operator, I want a read-only promotion preflight that tells me what is ready, blocked, or needs manual mapping before any cross-tenant write path exists.
**Why this priority**: Promotion language is not trustworthy until the product can explain why a target is or is not ready in a bounded, auditable way.
**Independent Test**: From an authorized compare preview, trigger the preflight action and verify that the page shows readiness counts, blocked reasons, and manual-mapping requirements without mutating source or target tenants.
**Acceptance Scenarios**:
1. **Given** a compare preview contains subjects with stable identity and usable target conditions, **When** the operator generates a promotion preflight, **Then** those subjects appear as ready with a clear explanation.
2. **Given** some subjects are missing identifiers, stale, or blocked by target conditions, **When** the operator generates the preflight, **Then** those subjects appear as blocked or manual-mapping-required with explicit reasons.
3. **Given** the operator generates a preflight, **When** the action completes, **Then** no target mutation, queued run, or provider write occurs.
4. **Given** the operator can view compare but lacks `WORKSPACE_BASELINES_MANAGE`, **When** they reach the compare page, **Then** the preflight action is visibly disabled with permission guidance and any forced request is rejected server-side.
---
### User Story 3 - Launch compare from portfolio context without losing return state (Priority: P2)
As a workspace operator, I want to enter compare from the tenant registry or portfolio-triage context and return without losing my working filters so compare becomes part of the portfolio workflow instead of a detached utility.
**Why this priority**: The workflow is much less useful if compare starts from scratch and breaks the operator's portfolio-review context.
**Independent Test**: Launch compare from the tenant registry with active triage filters, verify one tenant is prefilled, and verify the return path restores the prior registry state.
**Acceptance Scenarios**:
1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`.
2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored.
### Edge Cases
- source and target tenant are the same tenant: reject the selection as invalid input and do not compute preview or preflight
- source and target tenants belong to different workspaces
- one selected tenant is no longer visible or never belonged to the actor's scope
- compare subjects have ambiguous identity or duplicate matches
- target evidence is stale or missing, making readiness impossible to prove
## Requirements *(mandatory)*
### Functional Requirements
- **FR1**: The feature MUST provide one canonical workspace-context compare surface for selecting source and target tenants.
- **FR2**: The feature MUST enforce workspace membership and source/target tenant entitlement before loading compare data; inaccessible tenants resolve as `404`.
- **FR3**: The compare preview MUST use stable governed-subject identity and existing inventory/baseline compare seams rather than raw JSON diffing.
- **FR4**: The compare preview MUST stay read-only and MUST deep-link to existing tenant or baseline detail surfaces for proof instead of duplicating raw diagnostics locally.
- **FR5**: The feature MUST provide a read-only promotion preflight action that classifies subjects as ready, blocked, or manual-mapping-required.
- **FR6**: The preflight MUST NOT execute a target write, queue a run, or persist a promotion draft artifact.
- **FR7**: The preflight MUST explain blocked and manual states with explicit operator-readable reasons.
- **FR8**: The feature MUST reuse existing capability registries with this exact split: page access = `WORKSPACE_BASELINES_VIEW`, preview data = `TENANT_VIEW` on both tenants, preflight execution = `WORKSPACE_BASELINES_MANAGE`.
- **FR9**: The feature MUST preserve launch and return continuity from the tenant registry / portfolio-triage path.
- **FR10**: The feature MUST record bounded workspace audit metadata for promotion-preflight entry points only.
- **FR11**: The compare page MUST reject same-tenant selection before preview or preflight runs.
### Non-Functional Requirements
- **NFR1**: The feature MUST preserve workspace and tenant isolation and MUST NOT leak source or target hints to unauthorized actors.
- **NFR2**: The compare page MUST remain operator-first, decision-first, and must not expose raw payloads by default.
- **NFR3**: The implementation MUST remain Filament-native on Livewire v4 and must not introduce a second compare shell or custom status framework.
- **NFR4**: The slice MUST not introduce new assets or new globally searchable resources.
- NFR1: Enforce tenant isolation and least privilege across tenant selection and data access.
- NFR2: Comparison must not expose secrets or unsafe payload fields.
## Success Criteria
- **SC1**: An authorized operator can produce a cross-tenant compare preview from one canonical page without switching across multiple tenant detail surfaces.
- **SC2**: The same source, target, and filter selection produces reproducible compare output.
- **SC3**: A promotion preflight clearly separates ready, blocked, and manual subjects without performing any write.
- **SC4**: Unauthorized source/target combinations remain deny-as-not-found.
- **SC5**: View-only members can inspect compare results but cannot execute preflight, and the UI makes that boundary explicit.
- SC1: Operators can identify which tenant differs for a given policy type in under 2 minutes.
- SC2: Read-only comparisons are reproducible when run again with the same scope.
## Out of Scope
- Bulk remediation without preview/confirmation.
## Related Specs
- Program: `specs/039-inventory-program/spec.md`
- Core: `specs/040-inventory-core/spec.md`
- UI: `specs/041-inventory-ui/spec.md`
- Drift: `specs/044-drift-mvp/spec.md`
- Foundation follow-up context: `docs/product/spec-candidates.md` (`Cross-Tenant Compare and Promotion v1`)

View File

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

View File

@ -1,61 +0,0 @@
# Specification Quality Checklist: Plans, Entitlements & Billing Readiness
**Purpose**: Validate full preparation-package completeness and implementation readiness after planning and task generation
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Business value and operator outcomes stay explicit
- [x] The first slice is bounded to one workspace plan profile, two entitlement keys, two first enforcement points, and one read-only system summary
- [x] Runtime-governance sections are present for a future runtime feature, not treated as docs-only
- [x] All mandatory sections are completed
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Acceptance scenarios are defined for the primary user journeys
- [x] Edge cases are identified, including over-limit workspaces, existing artifacts, and unchanged queued runs
- [x] Scope is clearly bounded away from checkout, invoices, payment providers, proration, trials, grace periods, and a customer-account domain
- [x] Dependencies, assumptions, risks, and follow-up candidates are identified
## Feature Readiness
- [x] The first slice is small enough for bounded later planning
- [x] Concrete repo surfaces are named for settings, onboarding, review-pack generation, and system visibility
- [x] Follow-up commercial work is separated from the current slice instead of hidden inside it
- [x] No unresolved product question blocks `/speckit.implement` once artifact analysis passes
- [x] The selected candidate remains recognizable while the implementation slice stays narrow
## Governance Readiness
- [x] Workspace-owned settings are explicitly chosen over a new billing/account persistence model
- [x] Capability-first RBAC and 404 versus 403 semantics remain explicit
- [x] Entitlement denials are separated from RBAC denials and described as truthful product-state blocks
- [x] System-plane visibility is read-only and auditable in the first slice
- [x] Operator-facing surfaces include the required UI contract sections and action matrix
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, confirmation expectations for destructive actions, and no asset-strategy changes are explicit in the package
## Test Governance Review
- [x] Lane fit stays in focused unit plus feature validation only
- [x] Fixture and helper growth stays local to workspace, tenant, review-pack, and platform-directory contexts
- [x] No browser or heavy-governance family is introduced implicitly
- [x] Minimal validation commands are explicit in the spec
- [x] Runtime impact is treated as a real future feature, not as a documentation-only update
## Review Outcome
- [x] Review outcome class: `keep`
- [x] Workflow outcome: `keep`
- [x] Next command readiness: `/speckit.implement` after analyze issues are cleared
## Notes
- This checklist now validates the full preparation package: spec, plan, supporting design artifacts, and tasks. It does not imply that application code already exists.
- The first slice intentionally stops before trial or grace lifecycle state, payment integration, broader plan matrices, or any customer-account domain.
- System-plane mutation is deferred on purpose; the first slice keeps system visibility read-only to avoid creating a second commercial source of truth.
- Implementation close-out note (2026-04-27): the bounded slice has now been implemented and the focused validation lanes completed. The final post-format review-pack and system-directory lane passed at `31 passed (133 assertions)`.
- Browser smoke close-out note (2026-04-27): an integrated-browser smoke attempt was made because the slice changed user-facing Filament surfaces, but the environment could not provide a reliable authenticated tenant/system panel context. The smoke result is therefore classified as environment-blocked rather than pass/fail.
- Shared-surface note (2026-04-27): the final proof for blocked review-pack actions relies on the shared operator-facing tooltip helper text plus disabled action state. Direct tooltip-object inspection on the wrapped recordless Filament header action was not stable enough to serve as the final regression check.

View File

@ -1,456 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Admin/System — Workspace Entitlements Foundation (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the workspace-first entitlement foundation.
NOTE: These routes are implemented as existing Filament pages, widgets,
resources, and Livewire-backed actions. The exact Livewire payload shape is
not part of this contract. This file captures the user-visible routes,
logical action boundaries, and the required 404 / 403 / business-state
blocking semantics for the first slice.
servers:
- url: /admin
- url: /system
paths:
/settings/workspace:
get:
summary: View workspace entitlement settings
description: |
Renders the existing workspace settings singleton page with one new
entitlement section.
Behavior:
- No workspace selected: redirect to `/admin/choose-workspace`
- Non-member or wrong workspace: 404
- Workspace member without `workspace_settings.view`: 403
- Authorized member: render plan profile, effective entitlements,
source labels, rationale, and current usage summary
responses:
'200':
description: Workspace settings page rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/WorkspaceEntitlementSettingsView'
'302':
description: Redirect to choose-workspace when no workspace is active
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/settings/workspace/actions/save-entitlements:
post:
summary: Save plan profile and explicit entitlement overrides
description: |
Conceptual contract for the existing singleton settings save action.
The save reuses existing workspace-setting persistence and audit logging.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WorkspaceEntitlementSettingsCommand'
responses:
'204':
description: Settings saved successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/settings/workspace/actions/reset-entitlement-override/{entitlementKey}:
post:
summary: Reset one explicit entitlement override and rationale
description: |
Conceptual contract for a confirmation-protected override reset action.
Resetting returns effective truth to the selected plan profile or the
code-owned default profile.
parameters:
- $ref: '#/components/parameters/EntitlementKey'
responses:
'204':
description: Override reset successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}:
get:
summary: View onboarding workflow with entitlement-aware completion state
description: |
Renders the existing managed-tenant onboarding wizard. The completion
step must include managed-tenant activation entitlement truth.
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'200':
description: Onboarding wizard rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/OnboardingEntitlementView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}/actions/complete:
post:
summary: Complete onboarding when entitlement and existing readiness allow
description: |
Conceptual contract for the existing confirmation-protected completion
action. The entitlement gate must run before any tenant activation
mutation occurs.
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'204':
description: Onboarding completed
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/actions/generate:
post:
summary: Generate a review pack from the current tenant context
description: |
Conceptual contract for the tenant dashboard widget and review-pack list
generate action family. Existing dedupe and queued-start behavior remain
unchanged when entitlement allows execution.
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Generation accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/tenant-reviews/{tenantReview}/actions/export-executive-pack:
post:
summary: Export an executive pack from an existing tenant review
description: |
Conceptual contract for the review register and tenant review detail
export action family. The entitlement gate must run before any new
`ReviewPack` or `OperationRun` is created.
parameters:
- $ref: '#/components/parameters/TenantReviewId'
responses:
'202':
description: Export accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/{reviewPack}/actions/regenerate:
post:
summary: Regenerate an existing review pack
description: |
Conceptual contract for the existing review-pack detail regenerate
action. Existing confirmation and reuse behavior remain in place.
parameters:
- $ref: '#/components/parameters/ReviewPackId'
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Regeneration accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/directory/workspaces/{workspace}:
get:
summary: View read-only workspace entitlement summary in the system plane
description: |
Renders the existing system directory workspace detail page with a
read-only entitlement summary.
Behavior:
- Platform user with `platform.directory.view`: 200
- Platform user without that capability: 403
- Wrong-plane or non-platform actor: 404 semantics at the panel boundary
parameters:
- $ref: '#/components/parameters/WorkspaceId'
responses:
'200':
description: System workspace detail rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/SystemWorkspaceEntitlementView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
components:
parameters:
WorkspaceId:
name: workspace
in: path
required: true
schema:
type: integer
OnboardingDraftId:
name: onboardingDraft
in: path
required: true
schema:
type: integer
TenantReviewId:
name: tenantReview
in: path
required: true
schema:
type: integer
ReviewPackId:
name: reviewPack
in: path
required: true
schema:
type: integer
EntitlementKey:
name: entitlementKey
in: path
required: true
schema:
type: string
enum:
- managed_tenant_activation_limit
- review_pack_generation_enabled
responses:
Forbidden:
description: Member or platform user lacks the required capability in an already established scope
NotFound:
description: Wrong plane, non-member scope, or inaccessible record
BusinessStateBlocked:
description: Actor is otherwise authorized, but the workspace is not entitled for the requested action
content:
application/json:
schema:
$ref: '#/components/schemas/EntitlementBlockResponse'
ValidationError:
description: Submitted entitlement settings failed validation
schemas:
WorkspaceEntitlementSettingsCommand:
type: object
required:
- plan_profile
- entitlements
properties:
plan_profile:
type: string
nullable: true
description: Null means use the code-owned default profile
entitlements:
type: array
items:
$ref: '#/components/schemas/EntitlementOverrideInput'
EntitlementOverrideInput:
type: object
required:
- key
properties:
key:
type: string
enum:
- managed_tenant_activation_limit
- review_pack_generation_enabled
override_value:
oneOf:
- type: integer
- type: boolean
nullable: true
rationale:
type: string
nullable: true
ReviewPackGenerationCommand:
type: object
properties:
include_pii:
type: boolean
include_operations:
type: boolean
WorkspaceEntitlementSettingsView:
type: object
required:
- workspace_id
- effective_plan_profile
- entitlements
- primary_action
properties:
workspace_id:
type: integer
effective_plan_profile:
$ref: '#/components/schemas/PlanProfileSummary'
entitlements:
type: array
items:
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
last_changed:
$ref: '#/components/schemas/LastChangedAttribution'
nullable: true
primary_action:
$ref: '#/components/schemas/NextAction'
OnboardingEntitlementView:
type: object
required:
- draft_id
- completion_decision
properties:
draft_id:
type: integer
completion_decision:
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
primary_action:
$ref: '#/components/schemas/NextAction'
blocked_reason:
type: string
nullable: true
SystemWorkspaceEntitlementView:
type: object
required:
- workspace_id
- effective_plan_profile
- entitlements
properties:
workspace_id:
type: integer
effective_plan_profile:
$ref: '#/components/schemas/PlanProfileSummary'
entitlements:
type: array
items:
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
last_changed:
$ref: '#/components/schemas/LastChangedAttribution'
nullable: true
PlanProfileSummary:
type: object
required:
- id
- label
properties:
id:
type: string
label:
type: string
description:
type: string
nullable: true
source:
type: string
enum: [workspace_selection, code_default]
WorkspaceEntitlementDecision:
type: object
required:
- key
- effective_value
- source
- is_blocked
properties:
key:
type: string
enum:
- managed_tenant_activation_limit
- review_pack_generation_enabled
effective_value:
oneOf:
- type: integer
- type: boolean
source:
type: string
enum: [plan_profile_default, workspace_override]
rationale:
type: string
nullable: true
current_usage:
type: integer
nullable: true
remaining_capacity:
type: integer
nullable: true
is_blocked:
type: boolean
block_reason:
type: string
nullable: true
LastChangedAttribution:
type: object
required:
- at
- by
properties:
at:
type: string
format: date-time
by:
type: string
EntitlementBlockResponse:
type: object
required:
- key
- reason
properties:
key:
type: string
reason:
type: string
source:
type: string
enum: [plan_profile_default, workspace_override]
current_usage:
type: integer
nullable: true
effective_value:
oneOf:
- type: integer
- type: boolean
NextAction:
type: object
required:
- label
- kind
properties:
label:
type: string
kind:
type: string
enum:
- save_entitlements
- reset_override
- complete_onboarding
- generate_pack
- export_executive_pack
- regenerate_pack
- open_admin_workspace
action_name:
type: string
nullable: true
url:
type: string
nullable: true

View File

@ -1,148 +0,0 @@
# Data Model: Plans, Entitlements & Billing Readiness
**Date**: 2026-04-27
**Branch**: `247-plans-entitlements-billing-readiness`
## Overview
This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while plan defaults and effective entitlement decisions remain derived.
## Persisted Truth
### 1. Workspace Entitlement Settings Aggregate
**Persistence**: Existing `App\Models\WorkspaceSetting` rows
**Ownership**: Workspace-owned
**Scope**: One workspace, no tenant-owned persistence, no system-plane mutation
The slice reuses explicit settings keys under an `entitlements` domain.
| Setting key | Type | Nullable | Validation | Notes |
|-------------|------|----------|------------|-------|
| `entitlements.plan_profile` | string | yes | must match a code-owned plan-profile identifier when present | `null` means use the code-owned default profile |
| `entitlements.managed_tenant_limit_override_value` | int | yes | integer, `>= 0` | Explicit override for onboarding activation limit |
| `entitlements.managed_tenant_limit_override_reason` | string | yes | required when the paired override value is present; trimmed; max 500 chars | Operator-entered rationale shown on admin and system surfaces |
| `entitlements.review_pack_generation_override_value` | bool | yes | boolean | Explicit override for whether new `Generate pack`, `Regenerate`, and `Export executive pack` actions are allowed |
| `entitlements.review_pack_generation_override_reason` | string | yes | required when the paired override value is present; trimmed; max 500 chars | Operator-entered rationale shown on admin and system surfaces |
**Write rules**:
- Saving the section may update several `WorkspaceSetting` rows in one page submission, but each row continues to use the existing `SettingsWriter` audit path.
- Resetting an override clears both the override value and its rationale, returning effective truth to the selected plan profile or code-owned default profile.
- Lowering the managed-tenant limit below current usage does not mutate tenant records; it only changes future activation eligibility.
**Relationships**:
- `workspace_settings.workspace_id` anchors all persisted truth to a workspace.
- `workspace_settings.updated_by_user_id` remains the attribution source for last change metadata.
## Code-Owned Truth
### 2. Workspace Plan Profile Catalog Entry
**Persistence**: none, code-owned
**Ownership**: Product/runtime configuration
**Scope**: first-slice only
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | string | yes | Stable internal identifier stored in `entitlements.plan_profile` |
| `label` | string | yes | Operator-facing plan profile label on settings and system surfaces |
| `description` | string | yes | Concise explanation of what the profile allows |
| `managed_tenant_limit_default` | int | yes | Default active managed-tenant activation limit |
| `review_pack_generation_default` | bool | yes | Default allow/block state for new review-pack generation |
| `is_default` | bool | yes | Exactly one profile is the code-owned fallback when no workspace setting exists |
**Rules**:
- The catalog is intentionally bounded to the first slice and must not grow into a broader entitlement matrix in this feature.
- The catalog is not operator-editable and is not a contract, invoice, or subscription record.
## Derived Truth
### 3. Effective Workspace Entitlement Decision
**Persistence**: none, derived at runtime
**Owner**: bounded `WorkspaceEntitlementResolver`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `workspace_id` | int | yes | Workspace being evaluated |
| `plan_profile_id` | string | yes | Effective profile after applying the code-owned default fallback |
| `key` | string | yes | One of the two first-slice entitlement keys |
| `effective_value` | int or bool | yes | Final value after plan defaults plus any workspace override |
| `source` | string | yes | `plan_profile_default` or `workspace_override` |
| `rationale` | string | no | Override reason when source is `workspace_override`; otherwise optional plan-profile description |
| `current_usage` | int | no | Active managed-tenant count for the limit-based key; `null` for the boolean key |
| `remaining_capacity` | int | no | Derived only for the limit-based key |
| `is_blocked` | bool | yes | Whether the current action should stop for business-state reasons |
| `block_reason` | string | no | Operator-facing explanation used on onboarding and review-pack surfaces when blocked |
| `last_changed_at` | datetime | no | Derived from the most recent entitlement-related `WorkspaceSetting` row if present |
| `last_changed_by` | string | no | Derived actor attribution for settings and system visibility |
**Key catalog**:
| Entitlement key | Value type | Used by |
|-----------------|------------|---------|
| `managed_tenant_activation_limit` | int | `ManagedTenantOnboardingWizard` completion eligibility and summary |
| `review_pack_generation_enabled` | bool | `ReviewPackService`, tenant dashboard widget, review register, tenant review view, review-pack list/detail actions |
**Behavior rules**:
- `managed_tenant_activation_limit` compares `current_usage` to the effective limit and blocks only future onboarding activation.
- `review_pack_generation_enabled=false` blocks new generate, regenerate, and executive-pack export attempts before `ReviewPack` or `OperationRun` creation.
- Existing review-pack downloads and already-generated artifacts remain outside this entitlement decision.
## Supporting Derived View Models
### 4. Workspace Entitlement Section Read Model
**Persistence**: none
**Consumer**: `App\Filament\Pages\Settings\WorkspaceSettings`
Contains:
- effective plan profile label and description
- both entitlement decisions
- editable override values plus rationale inputs
- current managed-tenant usage summary
- last changed attribution for the `entitlements` domain
### 5. System Workspace Entitlement Summary Read Model
**Persistence**: none
**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace` and `resources/views/filament/system/pages/directory/view-workspace.blade.php`
Contains:
- read-only effective plan profile label
- both entitlement decisions with source and rationale
- last changed attribution
- current managed-tenant usage summary
## Derived Query Dependencies
| Need | Source | Notes |
|------|--------|-------|
| Active managed-tenant usage | existing tenant/workspace runtime truth | Count active managed tenants for the current workspace only; no persisted counter needed |
| Last change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from entitlement-related settings rows only |
| Review-pack run creation proof | existing `review_packs` and `operation_runs` behavior | Used only in tests to prove blocked attempts create no new run |
## State Transitions
No new persisted lifecycle state is introduced.
Derived runtime states for the limit-based entitlement:
| State | Trigger | Consequence |
|-------|---------|-------------|
| `within_limit` | `current_usage < effective_value` | Onboarding completion may proceed if all other existing checks pass |
| `at_limit` | `current_usage >= effective_value` | Future onboarding completion is blocked with a truthful reason |
| `over_limit_after_lowering` | Workspace limit is lowered below current usage | Existing tenants stay active; future onboarding completion remains blocked until usage or limit changes |
Derived runtime states for the review-pack entitlement:
| State | Trigger | Consequence |
|-------|---------|-------------|
| `enabled` | effective boolean value is `true` | Existing review-pack start flow proceeds unchanged |
| `disabled` | effective boolean value is `false` | New generate/regenerate/export attempts block before run creation |

View File

@ -1,275 +0,0 @@
# Implementation Plan: Plans, Entitlements & Billing Readiness
**Branch**: `247-plans-entitlements-billing-readiness` | **Date**: 2026-04-27 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Extend the existing workspace settings foundation with one bounded workspace entitlement slice: one code-owned plan-profile catalog, two first-slice entitlement keys, explicit workspace override values plus rationale, and one derived decision path that surfaces effective value, source, rationale, and usage truth.
- Reuse `WorkspaceSetting`, `SettingsResolver`, `SettingsWriter`, `WorkspaceSettings`, the capability registries, `ManagedTenantOnboardingWizard`, `ReviewPackService`, the current review-pack Filament entry points, and the system directory workspace detail page rather than introducing a billing/account domain.
- Hard enforcement remains narrow: block onboarding activation before tenant lifecycle mutation and block review-pack generation before `ReviewPack` or `OperationRun` creation, while preserving existing queued-start UX when entitlement allows execution.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4 + Laravel 12, existing workspace settings stack, `ReviewPackService`, capability registries
**Storage**: PostgreSQL via existing `workspace_settings` persistence; no new table or billing/account model
**Testing**: Pest feature and unit tests via Laravel Sail
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Monorepo Laravel web application in `apps/platform`, using Filament admin and system panels
**Project Type**: web
**Performance Goals**: Resolve entitlement truth from existing settings plus one scoped usage aggregate; perform no new external calls during page render; preserve existing review-pack dedupe and queued-start behavior when allowed
**Constraints**: Keep scope to one workspace plan profile, two entitlement keys, explicit workspace overrides with rationale, read-only system visibility, 404 for non-members/wrong plane, 403 for members missing capability, truthful business-state block for otherwise authorized actors, and no checkout/invoice/provider/subscription lifecycle work
**Scale/Scope**: One new bounded entitlement resolver, one entitlement section on the existing workspace settings page, one onboarding completion gate, one review-pack action family gate, one read-only system summary, and focused Sail/Pest coverage
## Filament v5 / Panel Notes
- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, and Livewire-backed actions. No Livewire v3 assumptions or compatibility work are introduced.
- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`.
- **Global search**: No new globally searchable resource is introduced. Touched existing resources already have dedicated view pages where applicable, and current global-search behavior remains unchanged.
- **Destructive and high-impact actions**: Existing onboarding draft cancellation/deletion remain `->requiresConfirmation()` plus capability enforcement. Any new override-reset action must also require confirmation because it can change runtime access. Entitlement denials themselves are non-destructive business-state blocks, not hidden RBAC failures.
- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mixed
- **Shared-family relevance**: workspace settings, action gating/helper text, review-pack queued-start UX, read-only system diagnostics
- **State layers in scope**: page, detail
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first on workspace settings, onboarding completion, and review-pack entry points; diagnostics-second on the read-only system page; no raw payload disclosure in the first slice
- **Raw/support gating plan**: capability-gated system-plane diagnostics only; no new raw/support payload section on admin surfaces
- **One-primary-action / duplicate-truth control**: workspace settings remains the only mutation surface for commercial posture; onboarding and review-pack surfaces show only the decision truth needed for the current action; the system directory mirrors resolved truth read-only
- **Handling modes by drift class or surface**: review-mandatory for shared action-family gating and cross-plane wording consistency
- **Repository-signal treatment**: review-mandatory because the slice spans admin and system planes plus an OperationRun-starting workflow family
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned; all first-slice surfaces must consume the same resolved decision object or a thin projection from it
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `WorkspaceSetting`, `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, workspace settings audit logging, `ManagedTenantOnboardingWizard`, `ReviewPackService`, current review-pack action surfaces, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and the system directory workspace detail page
- **Shared abstractions reused**: `SettingsResolver`, `SettingsWriter`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Auth\Capabilities`, `App\Support\Auth\PlatformCapabilities`, `App\Support\Rbac\UiEnforcement`, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`
- **New abstraction introduced? why?**: one bounded `WorkspaceEntitlementResolver` is justified because existing settings helpers resolve individual keys but do not provide plan-profile defaults, override rationale, usage context, or action-ready allow/block truth across multiple surfaces
- **Why the existing abstraction was sufficient or insufficient**: existing settings infrastructure is sufficient for persistence, validation, and audit; it is insufficient for multi-surface commercial decision truth because the same effective result must drive settings readback, onboarding activation gating, review-pack gating, and system diagnostics
- **Bounded deviation / spread control**: hard enforcement for review packs belongs in `ReviewPackService` and onboarding activation belongs in the existing wizard action; UI surfaces may project the same decision but must not create page-local entitlement rules
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: existing shared review-pack OperationRun start UX through `ReviewPackService`, `OperationUxPresenter`, and `OperationRunLinks`
- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe messaging, blocked-before-start behavior, tenant/workspace-safe URL resolution, and existing terminal notifications remain on the current shared path when entitlement allows generation
- **Surface-owned behavior kept local**: workspace settings helper text, onboarding completion blocked explanation, and review-pack helper text/disabled state remain local projections of the resolved decision
- **Queued DB-notification policy**: unchanged explicit opt-in only; blocked attempts create no run and therefore no queued notification
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: workspace commercial vocabulary, plan profile labels, entitlement source labels, override rationale, read-only support visibility
- **Neutral platform terms / contracts preserved**: `workspace`, `plan profile`, `managed tenant limit`, `review pack generation`, `override reason`, `source`
- **Retained provider-specific semantics and why**: none; review-pack generation is provider-backed operationally, but the new entitlement vocabulary remains platform-core and provider-neutral
- **Bounded extraction or follow-up path**: none
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS - this slice adds workspace-owned product truth, not new inventory or snapshot semantics.
- Read/write separation: PASS - settings writes stay inside the existing audited settings path; onboarding and review-pack enforcement block before mutation or run creation; high-impact reset actions remain confirmation-protected.
- Graph contract path: PASS - no new Graph calls are introduced; entitlement evaluation is local to settings, workspace counts, and existing review-pack start logic.
- Deterministic capabilities: PASS - capability checks remain registry-backed through `Capabilities` and `PlatformCapabilities`.
- RBAC-UX: PASS - `/admin` and `/system` stay separated; wrong-plane and non-member access remain 404; member-without-capability remains 403; no raw capability strings are introduced.
- Workspace isolation: PASS - workspace membership and workspace context remain required for admin-plane surfaces.
- RBAC-UX destructive confirmation: PASS - existing onboarding destructive actions already confirm, and any new override-reset action must also use `->requiresConfirmation()`.
- RBAC-UX global search: PASS - no new searchable resource or search scope is added.
- Tenant isolation: PASS - onboarding and review-pack surfaces remain tenant-safe; no cross-tenant leakage is introduced.
- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked attempts stop before `OperationRun` creation.
- OperationRun start UX: PASS - shared review-pack start UX is preserved; no local queued-toast composition is planned.
- Ops-UX 3-surface feedback: PASS - existing review-pack feedback stays toast + progress surfaces + terminal notification only when a run exists.
- Ops-UX lifecycle: PASS - no new `OperationRun` transitions are introduced.
- Ops-UX summary counts: N/A - no summary-count shape change is planned.
- Ops-UX guards: N/A - no new OperationRun guard rule is required for the planning slice.
- Ops-UX system runs: N/A - no initiator-null behavior is touched.
- Automation: N/A - no new queued or scheduled workflow family is introduced.
- Data minimization: PASS - no secrets, billing payloads, or provider credentials are persisted for entitlements.
- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit plus feature lanes with explicit commands and local fixtures only.
- Proportionality (PROP-001): PASS - persistence stays in existing workspace settings; only one bounded resolver is added for multi-surface truth.
- No premature abstraction (ABSTR-001): PASS - no new registry, interface, or framework is planned; the profile catalog remains plain code-owned data.
- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced; all new truth stays in existing `workspace_settings` rows.
- Behavioral state (STATE-001): PASS - over-limit and blocked states remain derived behavior, not a new persisted lifecycle model.
- UI semantics (UI-SEM-001): PASS - the design prefers direct mapping from resolved decision truth to UI helper text or summary rows.
- Shared pattern first (XCUT-001): PASS - the design reuses existing settings, audit, review-pack, and capability paths first.
- Provider boundary (PROV-001): PASS - the entitlement vocabulary remains platform-core and provider-neutral.
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - the narrow shape is explicit settings keys plus one resolver and thin UI projections.
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the only added structural element is one resolver, justified below.
- Badge semantics (BADGE-001): PASS - if source or availability is badged later, implementation must reuse existing badge infrastructure or stay text-only; no page-local badge taxonomy is planned.
- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, widgets, resources, and the existing system detail view.
- Filament-native UI local Blade/Tailwind: PASS - the only custom view touch remains the current system directory Blade view, which must preserve existing Filament visual language.
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing singleton settings, guided workflow, action family, and read-only detail surface types remain intact.
- Decision-first operating model (DECIDE-001): PASS - workspace settings remains primary, onboarding and review packs stay contextual decision points, and the system page remains tertiary diagnostics.
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - settings and action surfaces stay operator-first, while the system page is support-platform and read-only.
- UI/UX inspect model (UI-HARD-001): PASS - no duplicate inspect affordances are added.
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - no new parallel action hierarchy is introduced; current action families remain primary where already present.
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - product-facing labels remain narrow and non-billing.
- UI/UX placeholder ban (UI-HARD-001): PASS - no placeholder action groups are planned.
- UI naming (UI-NAMING-001): PASS - labels remain `Plan profile`, `Managed tenant limit`, `Review pack generation`, and `Override reason`.
- Operator surfaces (OPSURF-001): PASS - mutation scope is explicit and system-plane visibility remains read-only.
- Operator surface page contract: PASS - the spec already defines the required page and action contracts.
- Filament UI Action Surface Contract: PASS - touched surfaces already have action contracts or exemptions; the plan preserves them while adding entitlement truth.
- Filament UI UX-001 (Layout & IA): PASS - no new page shell or alternate layout is planned.
- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - workspace settings remains the primary configuration surface; review-pack generation remains the primary reporting action where already present.
- UI review workflow: PASS - guardrail, shared-family, and exception posture remain explicit in this plan.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for the bounded resolver and profile defaults; `Feature` for workspace settings, onboarding, review-pack entry surfaces, and the system directory page
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: the business truth is a deterministic resolver plus existing Filament/Livewire action paths; browser and heavy-governance coverage would add cost without proving extra risk for this bounded slice
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
- **Fixture / helper / factory / seed / context cost risks**: local workspace, workspace membership, active managed-tenant count, tenant review/review-pack context, and platform-user fixtures only
- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and scope helpers with opt-in entitlement fixtures
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief for workspace settings and onboarding; shared-detail-family coverage for review-pack entry points; one read-only system detail assertion for the system plane
- **Closing validation and reviewer handoff**: rerun the exact targeted Sail/Pest commands above and verify 404/403/business-state semantics separately, verify blocked review-pack attempts create no `OperationRun`, and verify lowered limits do not mutate existing tenants
- **Budget / baseline / trend follow-up**: none expected beyond normal feature-local growth
- **Review-stop questions**: lane fit, hidden fixture cost, service-level bypass risk on review-pack generation, and cross-plane wording drift
- **Escalation path**: none
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one resolver and four existing surface families; no new heavy family or platform-wide harness is introduced
## Project Structure
### Documentation (this feature)
```text
specs/247-plans-entitlements-billing-readiness/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── workspace-entitlements-foundation.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Reviews/ReviewRegister.php
│ │ │ ├── Settings/WorkspaceSettings.php
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
│ │ ├── Resources/
│ │ │ ├── ReviewPackResource.php
│ │ │ ├── ReviewPackResource/Pages/ListReviewPacks.php
│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php
│ │ │ ├── TenantReviewResource.php
│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php
│ │ ├── System/Pages/Directory/ViewWorkspace.php
│ │ └── Widgets/Tenant/TenantReviewPackCard.php
│ ├── Models/WorkspaceSetting.php
│ ├── Services/
│ │ ├── ReviewPackService.php
│ │ ├── Settings/SettingsResolver.php
│ │ ├── Settings/SettingsWriter.php
│ │ └── Entitlements/WorkspaceEntitlementResolver.php # likely new bounded service
│ ├── Support/
│ │ ├── Auth/Capabilities.php
│ │ ├── Auth/PlatformCapabilities.php
│ │ └── Settings/SettingsRegistry.php
├── tests/
│ ├── Feature/
│ └── Unit/
└── resources/views/filament/system/pages/directory/view-workspace.blade.php
```
**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded entitlement resolver and changes limited to existing settings, onboarding, review-pack, and system-directory surfaces plus focused Pest coverage.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New bounded entitlement resolver | Multiple existing surfaces need the same effective plan-default versus override decision, source attribution, rationale, and usage truth | Surface-local checks in `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, and the review-pack action family would drift immediately and duplicate business-state wording |
## Proportionality Review
- **Current operator problem**: The product cannot currently answer, in one auditable path, whether a workspace may activate another managed tenant or generate a review pack, nor can it show why a manual override exists.
- **Existing structure is insufficient because**: raw settings rows and direct capability checks do not produce plan-profile defaults, source attribution, override rationale, usage context, or reusable allow/block truth for multiple surfaces.
- **Narrowest correct implementation**: persist only workspace-selected plan profile and explicit override values plus rationale through existing `WorkspaceSetting` rows, keep plan defaults code-owned, and add one bounded resolver that derives effective decisions for the four affected surface families.
- **Ownership cost created**: one small default catalog plus one resolver require focused unit tests and wording discipline across settings, onboarding, review-pack, and system visibility.
- **Alternative intentionally rejected**: a new `Plan`, `Subscription`, `CustomerAccount`, or broad entitlement matrix domain was rejected because the spec only needs workspace-owned current-release truth for two entitlement keys.
- **Release truth**: current-release truth
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/research.md`
Goals:
- Confirm the narrowest reuse of the existing workspace settings stack for plan profile and override persistence.
- Confirm the exact service and page-level enforcement points that prevent onboarding activation or review-pack run creation before mutation.
- Confirm how to preserve existing review-pack OperationRun UX while inserting entitlement checks ahead of run creation.
- Confirm system/admin plane separation and read-only directory visibility requirements for support users.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
Design focus:
- Represent workspace commercial truth with explicit `WorkspaceSetting` keys under an `entitlements` domain rather than a new model family.
- Keep the plan-profile catalog code-owned and small, and keep the new logic surface to one bounded `WorkspaceEntitlementResolver` rather than a registry or framework.
- Extend `WorkspaceSettings` with one entitlement section that edits plan profile and override/rationale pairs using existing settings write and reset patterns.
- Use the same decision path in `ManagedTenantOnboardingWizard` completion state and in review-pack generation entry surfaces, with hard enforcement centralized in `completeOnboarding()` and `ReviewPackService` before any mutation or run creation occurs.
- Extend `App\Filament\System\Pages\Directory\ViewWorkspace` plus its Blade view with a read-only entitlement summary instead of adding a second mutation plane.
## Phase 1 — Agent Context Update
After Phase 1 artifacts are generated, update Copilot context from the completed plan:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created later by `/speckit.tasks`)
- Add bounded entitlement settings definitions and the plan-profile default map without creating new persistence tables.
- Implement `WorkspaceEntitlementResolver` to merge code-owned plan defaults, workspace overrides, override rationale, and current usage for the managed-tenant limit.
- Extend `WorkspaceSettings` with a plan profile selector, two override controls, rationale inputs, resolved-source helper text, and confirmed reset actions.
- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared entitlement decision and preserve existing confirmation plus audit semantics.
- Gate every current review-pack generate/regenerate/export entry point through the shared decision, with service-level enforcement in `ReviewPackService` to prevent bypass and preserve existing OperationRun UX when allowed.
- Add a read-only entitlement summary to the system directory workspace detail page and preserve system-plane-only visibility.
- Add focused Sail/Pest unit and feature coverage for resolver behavior, settings save/reset, onboarding blocking, review-pack no-run blocking, and system visibility.
## Constitution Check (Post-Design)
Re-check result: PASS. The design keeps persistence inside existing workspace settings, adds only one bounded resolver, preserves Filament v5 + Livewire v4 surfaces, keeps panel provider registration unchanged in `bootstrap/providers.php`, leaves global search and asset strategy unchanged, enforces 404/403 semantics separately from business-state blocks, and preserves existing review-pack `OperationRun` UX by gating before run creation instead of replacing shared run infrastructure.
## Guardrail Close-Out
- Outcome: keep
- Livewire v4.0+ compliance remained intact across the touched Filament v5 pages, widgets, resources, and Livewire-backed actions.
- Provider registration location remains unchanged in `bootstrap/providers.php`; no panel registration changes were needed.
- Global-search scope remains unchanged; no new searchable resources were introduced.
- Destructive actions remain confirmation-protected where applicable. The existing `regenerate` review-pack action keeps its confirmation requirement, while the new entitlement denials are non-destructive business-state blocks enforced before `ReviewPack` or `OperationRun` creation.
- Asset strategy remains unchanged. No new Filament assets were added; deploy behavior still uses `cd apps/platform && php artisan filament:assets` when registered assets are shipped.
- Validation lanes completed:
- Targeted unit entitlement lane: completed earlier in the feature implementation loop for `WorkspaceEntitlementResolver` and `WorkspacePlanProfileCatalog`.
- Targeted settings and onboarding feature lane: completed earlier in the feature implementation loop for workspace settings and managed-tenant onboarding gating.
- Targeted review-pack and system-directory feature lane: `31 passed (133 assertions)` on the final post-format run.
- Browser smoke note: attempted because changed surfaces were user-facing, but classified as environment-blocked. The integrated browser could reach `/admin` with a synthetic session cookie, but tenant-panel route resolution stayed on 404s and the system panel continued redirecting to `/system/login`, so no reliable PASS/FAIL smoke result could be established.
- Document-in-feature note: shared review-pack blocked-state wording remains centralized in the resource/service helper path. Direct tooltip introspection on the wrapped recordless Filament header action was not stable proof in the test harness, so the final assertion strategy validates the shared tooltip helper text and disabled UI state instead of comparing the wrapped action tooltip object directly.

View File

@ -1,99 +0,0 @@
# Quickstart: Plans, Entitlements & Billing Readiness
**Date**: 2026-04-27
**Branch**: `247-plans-entitlements-billing-readiness`
This quickstart is the intended reviewer flow after implementation. It stays bounded to the first slice described in the spec.
## Prerequisites
1. Start the local platform stack.
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
2. Ensure one workspace member has `workspace_settings.manage`, one workspace owner can complete onboarding, one reporting operator can manage review packs, and one platform user has `platform.directory.view`.
3. Seed or factory-create:
- one workspace with no entitlement overrides
- one workspace at or above the managed-tenant activation limit
- one managed-tenant onboarding draft in the target workspace
- one tenant and one tenant review capable of review-pack generation
## Scenario 1: Configure workspace commercial truth
1. Open `/admin/settings/workspace` as a workspace manager.
2. Confirm the page shows a `Plan profile` selector and the two first-slice entitlement controls.
3. Save a plan profile with no overrides.
4. Confirm the page shows:
- the effective managed-tenant limit
- whether review-pack generation is enabled
- source labels pointing to the plan profile
- current managed-tenant usage
5. Add an explicit override and rationale for one entitlement.
6. Save again and confirm the effective source switches to workspace override and the rationale is visible.
7. Reset the override and confirm the effective value returns to the plan-profile default.
## Scenario 2: Gate managed-tenant onboarding activation
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace that is within limit.
2. Confirm the completion step shows the current active managed-tenant usage and allows `Complete onboarding`.
3. Repeat with a workspace at or above its limit.
4. Confirm:
- the completion action remains visible for an otherwise authorized actor
- the action explains why onboarding is blocked
- no tenant activation occurs
5. Repeat with a workspace override that raises the limit and confirm the source label changes to workspace override.
## Scenario 3: Gate review-pack generation without creating a run
1. Use a workspace where review-pack generation is enabled.
2. Trigger generation from each current entry family:
- tenant dashboard review-pack card
- review register export action
- tenant review detail export action
- review-pack list header generate action
- review-pack detail regenerate action
3. Confirm the current queued-start UX remains unchanged when allowed.
4. Switch to a workspace where review-pack generation is disabled.
5. Repeat the same actions and confirm:
- each surface shows the same entitlement-based reason
- no new `ReviewPack` row is created
- no new `OperationRun` row is created
- existing `View` and `Download` access to already-generated review packs still works under current artifact permissions
## Scenario 4: Inspect the read-only system summary
1. Open `/system/directory/workspaces/{workspace}` as a platform user with `platform.directory.view`.
2. Confirm the page shows:
- the effective plan profile
- both entitlement decisions
- source labels
- override rationale when present
- last changed attribution
3. Confirm there are no mutation controls on the system page.
## RBAC and Plane Semantics Checks
1. Access admin-plane entitlement surfaces as a non-member or wrong-workspace actor and confirm 404.
2. Access the same surfaces as a workspace member lacking the relevant capability and confirm 403.
3. Access the action as an otherwise authorized actor whose workspace is not entitled and confirm a truthful business-state block instead of 403 or 404.
4. Access the system page as an admin-plane actor and confirm wrong-plane behavior does not leak workspace entitlement truth.
## Targeted Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Out of Scope Confirmations
While validating this slice, confirm that the implementation does not add or imply:
- checkout or invoice UI
- payment-provider credentials or adapters
- customer-account, subscription, or contract records
- trial, grace-period, suspension, or renewal lifecycle states
- broader entitlement matrices outside the two first-slice keys

View File

@ -1,76 +0,0 @@
# Research: Plans, Entitlements & Billing Readiness
**Date**: 2026-04-27
**Branch**: `247-plans-entitlements-billing-readiness`
## Decision 1: Persist workspace commercial truth in existing `workspace_settings`
- **Decision**: Store the first-slice workspace commercial truth through explicit `WorkspaceSetting` keys in an `entitlements` domain, reusing `SettingsRegistry`, `SettingsResolver`, and `SettingsWriter`.
- **Rationale**: The repo already has validated, audited workspace-scoped settings persistence and a singleton workspace settings page. Reusing that path keeps the slice narrow, keeps audit behavior consistent, and avoids inventing a billing or account persistence model.
- **Alternatives considered**:
- New `plans`, `subscriptions`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope.
- One nested JSON blob for all entitlement fields: rejected because explicit keys better fit existing page save/reset patterns, validation, and audit attribution.
## Decision 2: Keep the plan-profile catalog code-owned and bounded
- **Decision**: Represent plan-profile defaults as a small code-owned catalog with one code-owned default profile and bounded named profile identifiers, not as operator-editable data.
- **Rationale**: The first slice needs deterministic defaults when no workspace-specific selection exists, but it does not need a management UI, a billing backoffice, or a pricing model. Code-owned defaults are the narrowest current-release truth.
- **Alternatives considered**:
- Database-backed plan catalog: rejected because there is no current product workflow for editing plans.
- External billing/provider sync: rejected because the spec explicitly excludes payment providers and subscription lifecycle work.
## Decision 3: Introduce one bounded `WorkspaceEntitlementResolver`
- **Decision**: Add one bounded resolver that projects effective entitlement decisions from plan defaults, workspace overrides, override rationale, and current usage.
- **Rationale**: Existing settings helpers resolve raw setting values but do not answer the operator question the feature actually needs: what is the effective value, where did it come from, why is it overridden, what is current usage, and may this action proceed now?
- **Alternatives considered**:
- Rebuild the logic independently on `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, and each review-pack entry surface: rejected because it would immediately create wording drift and inconsistent enforcement.
- Extend `SettingsResolver` to absorb entitlement-specific usage logic: rejected because that would over-specialize a generic settings utility.
## Decision 4: Keep hard enforcement at the existing mutation and run-start boundaries
- **Decision**: Enforce onboarding entitlement in `ManagedTenantOnboardingWizard::canCompleteOnboarding()` and `completeOnboarding()`, and enforce review-pack entitlement inside `ReviewPackService::generate()` and `generateFromReview()`, while UI surfaces render the same decision state ahead of action execution.
- **Rationale**: Review-pack generation already fans out through several Filament actions, but those surfaces converge on `ReviewPackService`. Putting hard enforcement at the service boundary prevents bypass. Onboarding completion is already owned by the wizard page and should remain there.
- **Alternatives considered**:
- UI-only disabling on each action surface: rejected because it would not protect direct Livewire action execution.
- A second cross-cutting action framework for entitlement checks: rejected because the slice only needs one bounded business decision path, not a new platform hook system.
## Decision 5: Preserve explicit RBAC versus business-state semantics
- **Decision**: Keep 404 for non-members and wrong-plane actors, keep 403 for members missing capability, and model entitlement denial as a visible business-state block for otherwise authorized actors.
- **Rationale**: The repo constitution already distinguishes membership isolation from capability denial. Entitlements are neither. Treating entitlement denial as 403 or 404 would erase the operator-visible truth this slice exists to provide.
- **Alternatives considered**:
- Hide blocked actions completely: rejected because the spec requires operator-visible rationale.
- Return 403 for entitlement denial: rejected because it conflates product policy with authorization.
## Decision 6: Keep system visibility read-only on the existing workspace directory page
- **Decision**: Expose the resolved plan profile, entitlement values, source, and last-changed attribution on `App\Filament\System\Pages\Directory\ViewWorkspace` and its existing Blade view, with no system-plane mutation control.
- **Rationale**: Platform support needs visibility into current workspace commercial truth, but introducing a second mutation plane would immediately create duplicate truth and cross-plane drift.
- **Alternatives considered**:
- New system resource or admin-like settings page: rejected because the first slice is explicitly read-only on `/system`.
- Linking support users back to `/admin` without any local visibility: rejected because it keeps support dependent on plane switching and tribal knowledge.
## Decision 7: Keep review-pack shared OperationRun UX unchanged when entitled
- **Decision**: Preserve existing `OperationUxPresenter`, `OperationRunLinks`, dedupe behavior, and queued background generation semantics whenever review-pack generation is entitled.
- **Rationale**: The feature is about whether generation is allowed, not about rebuilding review-pack run UX. The right insertion point is before run creation, not inside the shared run lifecycle.
- **Alternatives considered**:
- Localize new review-pack blocked/queued UX per surface: rejected because the repo already centralizes the run-start UX.
- Add a new entitlement-specific notification family: rejected because blocked attempts should stop quietly with truthful local action messaging and no new run.
## Decision 8: Prove the slice with focused Sail/Pest unit and feature coverage only
- **Decision**: Cover the new resolver/profile defaults with unit tests and prove settings, onboarding, review-pack gating, and system visibility with focused feature tests run through Sail.
- **Rationale**: The business risk is decision correctness and action enforcement, not browser layout or broad workflow orchestration. Unit plus feature lanes are enough to prove the slice without dragging in heavy-governance or browser cost.
- **Alternatives considered**:
- Browser tests: rejected because no browser-only interaction or layout risk is introduced.
- Heavy-governance suite expansion: rejected because the scope is bounded and local to existing surfaces.
## Decision 9: Leave Filament panel registration, global search, and assets unchanged
- **Decision**: Do not add panels, providers, global-search resources, or new Filament asset registrations as part of this slice.
- **Rationale**: The feature is workspace-first entitlement truth inside existing admin and system surfaces. Filament infrastructure changes would widen scope without helping the first release.
- **Alternatives considered**:
- New commercial panel or system sub-panel: rejected because the slice reuses current surfaces.
- Asset-backed custom billing UI components: rejected because native Filament components and the existing system Blade page are sufficient.

View File

@ -1,327 +0,0 @@
# Feature Specification: Plans, Entitlements & Billing Readiness
**Feature Branch**: `247-plans-entitlements-billing-readiness`
**Created**: 2026-04-27
**Status**: Draft
**Input**: User description: "Update the existing candidate as a workspace-first entitlement foundation. Keep the candidate title recognizable, but scope the first slice to one workspace-owned plan profile, explicit entitlement overrides with rationale, operator-visible decision truth, and first enforcement on managed-tenant onboarding activation plus review-pack generation. Reuse existing workspace settings, capability registries, onboarding, review-pack, and system-directory surfaces. Do not assume checkout, invoices, payment providers, proration, or a separate customer-account domain."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot has no product-owned workspace truth for what a workspace is commercially allowed to do, so plan availability and limit decisions live in founder memory, ad hoc support explanations, or scattered future guesses instead of one auditable runtime decision path.
- **Today's failure**: Authorized operators can reach activation or review-pack entry points without any product-side entitlement explanation, while the product cannot truthfully answer why a feature is unavailable, whether a workspace is over its allowed managed-tenant count, or which manual override currently applies.
- **User-visible improvement**: Workspace admins can set one plan profile and explicit override values once, and operators then see a calm but truthful allow-or-block reason directly on onboarding activation, review-pack generation, and system support views.
- **Smallest enterprise-capable version**: Extend the existing workspace settings foundation with one workspace plan profile, two first-slice entitlement keys, explicit workspace override values with rationale, one derived entitlement resolver, read-only system visibility, and server-enforced checks on managed-tenant onboarding activation plus review-pack generation.
- **Explicit non-goals**: No customer-account domain, no subscription lifecycle engine, no invoices, no checkout, no payment provider integration, no proration, no public pricing surface, no trial/grace/suspension workflow, no seat or report-retention matrix, no tenant/user/export/deletion entitlement spread beyond the two first-slice checks, and no platform-wide backoffice billing framework.
- **Permanent complexity imported**: One bounded plan-profile catalog, one bounded first-slice entitlement key catalog, one derived entitlement decision/resolution layer, one new entitlement section on the existing workspace settings page, one read-only system summary, and focused unit plus feature coverage.
- **Why now**: This candidate is upstream of customer lifecycle communication, demo and trial readiness, and broader commercial readiness. Adjacent self-service and support candidates are already specced, and they need a truthful entitlement source before later commercial workflows can remain narrow.
- **Why not local**: The same commercial truth must drive workspace settings, onboarding activation, review-pack generation, and system operator visibility. Local conditionals on each surface would drift immediately and recreate the current manual explanation problem.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New semantic axis, foundation-sounding theme, and multi-surface touchpoint. Defense: this slice is explicitly limited to existing workspace settings, two entitlement keys, two runtime enforcement points, and one read-only system summary. It does not introduce a customer-account model, payment flow, or broad plan matrix.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/settings/workspace` on `App\Filament\Pages\Settings\WorkspaceSettings`
- `/admin/onboarding/{onboardingDraft}` on `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
- `/admin/reviews` on `App\Filament\Pages\Reviews\ReviewRegister`
- existing review-pack generation entry surfaces on the tenant dashboard, tenant review detail pages, and review-pack registry/detail surfaces backed by `App\Services\ReviewPackService`
- `/system/directory/workspaces/{workspace}` on `App\Filament\System\Pages\Directory\ViewWorkspace`
- **Data Ownership**: Current-release entitlement truth is workspace-owned and stored through existing `WorkspaceSetting` records plus code-owned plan-profile defaults. Managed-tenant counts, review-pack runs, and existing artifacts remain derived from current workspace and tenant truth. No new billing/account/customer-subscription table is introduced.
- **RBAC**: Workspace membership remains the isolation boundary for `/admin`. `Capabilities::WORKSPACE_SETTINGS_VIEW` and `Capabilities::WORKSPACE_SETTINGS_MANAGE` govern configuration visibility and mutation. `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE` and `Capabilities::REVIEW_PACK_MANAGE` remain the execution capabilities on the first enforcement surfaces. `PlatformCapabilities::DIRECTORY_VIEW` governs read-only system visibility. Non-members and wrong-plane actors receive 404. Members missing capability receive 403. Members with capability but without entitlement receive a truthful business-state block rather than a hidden surface or false 403.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice is workspace-owned and does not introduce a tenantless cross-tenant collection that filters tenant records.
- **Explicit entitlement checks preventing cross-tenant leakage**: N/A - existing tenant access rules remain authoritative. The new workspace entitlement truth never reveals tenant-owned records outside the current workspace or platform directory visibility.
## 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)**: configuration settings, status messaging, action gating/helper text, review-pack start UX, read-only system diagnostics
- **Systems touched**: `WorkspaceSetting`, `SettingsResolver`, `SettingsWriter`, workspace audit logging, canonical capability registries, managed-tenant onboarding activation, review-pack generation entry surfaces, and the system directory workspace detail view
- **Existing pattern(s) to extend**: existing workspace settings update/reset + audit pattern, existing capability-gated Filament actions, existing review-pack queued-start UX, and existing system directory detail summaries
- **Shared contract / presenter / builder / renderer to reuse**: `App\Services\Settings\SettingsResolver`, `App\Services\Settings\SettingsWriter`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Auth\Capabilities`, `App\Support\Auth\PlatformCapabilities`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`; one new bounded `WorkspaceEntitlementResolver` (or equivalently named resolver) is introduced because there is no existing shared commercial-decision path
- **Why the existing shared path is sufficient or insufficient**: The existing settings stack is already sufficient for workspace-owned persistence, validation, and audit. It is insufficient for runtime commercial truth because multiple surfaces need the same resolved plan-default versus override decision, source attribution, and usage context without duplicating business rules.
- **Allowed deviation and why**: none. The feature must not create page-local entitlement checks, local billing copy, or a second support-facing commercial summary.
- **Consistency impact**: Plan profile labels, entitlement source labels, blocked copy, override rationale labels, and current-usage summaries must mean the same thing on workspace settings, onboarding activation, review-pack generation, and the system directory page.
- **Review focus**: Reviewers must verify that the same resolved decision object drives all first-slice surfaces, that no surface invents its own commercial vocabulary, and that existing review-pack operation UX remains unchanged when entitlement allows execution.
## 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
- **Shared OperationRun UX contract/layer reused**: Existing review-pack generation continues to use `App\Services\ReviewPackService`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`. Managed-tenant onboarding activation remains a confirmed, audited page mutation and does not introduce a new `OperationRun`.
- **Delegated start/completion UX behaviors**: When review-pack generation is entitled, queued toast, `Open operation` link, dedupe handling, browser event dispatch, and terminal lifecycle notifications stay on the existing shared path. When generation is not entitled, no run is created and no queued or terminal notification is emitted.
- **Local surface-owned behavior that remains**: Workspace settings save and reset actions, onboarding completion helper text and callouts, and blocked reason presentation on review-pack entry surfaces remain surface-owned.
- **Queued DB-notification policy**: unchanged. The feature adds no new queued DB notification behavior and no new review-pack run type.
- **Terminal notification path**: central lifecycle mechanism for existing review-pack generation only
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary touched. The new plan and entitlement vocabulary is platform-core and must remain provider-neutral even when it gates provider-backed review-pack generation.
## 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 |
|---|---|---|---|---|---|---|
| Workspace settings entitlement section | yes | Native Filament + existing singleton settings page | settings, status messaging, helper text | page, section, resolved settings summary | no | Extends the existing singleton page instead of creating a new admin surface |
| Managed tenant onboarding completion gate | yes | Native Filament wizard + existing completion action | action gating, callouts, helper text | wizard step, confirmation action | no | Reuses the existing completion step and keeps onboarding calm |
| Review-pack generation entry family | yes | Native Filament widget/resource actions | operation start gating, helper text, queued-start UX | widget action, detail action, list/header action | no | One entitlement decision must cover all current `Generate pack`, `Regenerate`, and `Export executive pack` entry points |
| System directory workspace entitlement summary | yes | Native Filament system detail page | read-only diagnostics, support/commercial visibility | detail section/card | no | Read-only only in the first slice |
## 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 |
|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | Primary Decision Surface | Workspace owner or manager decides what this workspace is commercially allowed to do | Current plan profile, effective entitlements, source, rationale, and current usage summary | Audit attribution and related affected surfaces | Primary because this is the one configuration point that changes later runtime behavior | Configuration first, enforcement second | Removes ad hoc founder-only plan decisions and scattered explanations |
| Managed tenant onboarding completion gate | Primary Decision Surface | Operator decides whether the tenant may be activated now | Activation eligibility, current active managed-tenant usage versus allowed limit, and the one next action | Existing verification and bootstrap diagnostics remain secondary | Primary because onboarding completion is the actual high-impact decision point for tenant activation | Keeps commercial truth inside the onboarding workflow instead of forcing cross-page lookup | Prevents silent failure or false calmness at the moment of activation |
| Review-pack generation entry family | Secondary Context Surface | Operator decides whether to start or retry review-pack generation from the current tenant or review context | Allow-or-block state, source, and the next step when blocked | Existing operation detail, review-pack status, and artifact truth stay secondary | Not primary because the surface exists to continue reporting/review workflows, not to manage commercial posture | Stays inside the existing reporting workflow | Avoids back-and-forth to support or settings just to understand why generation is blocked |
| System directory workspace entitlement summary | Tertiary Evidence / Diagnostics Surface | Platform operator or support user verifies what entitlement truth is active for a workspace | Resolved plan profile, effective entitlement values, source, and last changed attribution | Existing tenant counts, recent runs, and admin workspace link stay supporting context | Not primary because system operators inspect rather than change commercial posture in this slice | Supports support and escalation workflows without adding a second mutation plane | Avoids separate manual lookup across admin pages, audit logs, and founder memory |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | operator-MSP | Plan profile, two first-slice entitlements, current usage, source, and saveable override inputs | Last modified attribution and reset state | none | `Save` | Any future billing/account metadata and broader commercial lifecycle fields stay out of scope | The same resolved values shown here are reused on downstream surfaces instead of reworded locally |
| Managed tenant onboarding completion gate | operator-MSP | Activation eligibility, active managed-tenant count, limit source, and concise blocked reason | Verification and operability detail already present on the wizard | none | `Complete onboarding` or a clear blocked explanation when unavailable | Broader commercial configuration stays off the onboarding page | The onboarding step shows only the one commercial fact needed for activation and does not restate full settings data |
| Review-pack generation entry family | operator-MSP | Review-pack generation availability, source, and the next step when blocked | Existing queued/run state and artifact status remain secondary | none | The in-context start action: `Generate pack`, `Regenerate`, or `Export executive pack` when allowed | Full workspace plan configuration stays off these surfaces | The same entitlement reason object is rendered consistently across the widget, Review Register, tenant review detail, and review-pack resource actions |
| System directory workspace entitlement summary | support-platform | Read-only plan profile, effective entitlement values, source, and last changed attribution | Tenant counts, recent runs, and admin workspace link | none | `Open admin workspace` | Mutation controls and raw settings payload stay hidden in the system plane | The system page mirrors resolved truth only and does not become a second editable source |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | Config / Settings / Singleton | Workspace configuration page | Save or reset an entitlement-related setting | In-page settings section | forbidden | Per-field reset actions and helper text stay inside the section | None beyond confirmed reset of overrides if added | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Plan profile / Entitlements | Effective values, source, rationale, and usage | Singleton-settings exception already exists and remains bounded |
| Managed tenant onboarding completion gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because the workspace is over limit | In-page completion section | forbidden | Back-navigation and linked-tenant navigation stay secondary | Existing `Cancel draft` and `Delete draft` header actions remain destructive and confirmation-protected | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus linked tenant identity | Onboarding entitlement | Activation eligibility and current limit usage | Guided-workflow exception remains valid |
| Review-pack generation entry family | Contextual action family | Tenant widget plus Review Register, tenant review detail, and tenant-scoped review-pack registry/detail actions | Start or retry review-pack generation when allowed | Explicit action on the current tenant or review context | mixed - forbidden on widget/detail actions, existing clickable row remains on the registry | Existing `View` and `Download` actions remain secondary and outside the entitlement gate; `Generate pack`, `Regenerate`, and `Export executive pack` are the only in-scope gated actions | Existing expire or similar destructive actions remain where the current resource contract places them | current tenant dashboard, `/admin/reviews`, tenant review detail, and tenant review-pack registry | current tenant dashboard, tenant review detail, and tenant review-pack registry/view | Active workspace, active tenant, and current review or review-pack context | Review pack generation entitlement | Allowed or blocked state and why | Grouped-action family exception documented here to avoid divergent gating |
| System directory workspace entitlement summary | System / Detail / Diagnostics | Read-only workspace detail page | Inspect current workspace commercial truth | Dedicated workspace detail page | forbidden | Existing admin-workspace and runs links stay secondary | none | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity and tenant count | Workspace entitlement summary | Effective plan profile, source, and last change attribution | Read-only system diagnostic surface |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | Workspace owner or manager | Set or clear the workspace commercial posture for the first slice | Singleton settings page | What is this workspace allowed to do, and do I need to override the default? | Current plan profile, managed-tenant activation limit, review-pack generation availability, source, rationale, and current usage | Last modified attribution and reset availability | commercial profile, entitlement source, current usage | TenantPilot only | Save, Reset override | none |
| Managed tenant onboarding completion gate | Workspace owner completing managed tenant onboarding | Decide whether onboarding may be completed now | Guided workflow step | Can I activate this managed tenant under the current workspace entitlements? | Active managed-tenant usage, allowed limit, source, blocked reason, and existing completion prerequisites | Existing verification/operability diagnostics | onboarding readiness, entitlement eligibility | TenantPilot only for completion state; Microsoft tenant only for existing provider actions already on the page | Complete onboarding | Cancel draft, Delete draft |
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether to start or retry review-pack generation | Widget/action family | Can I start `Generate pack`, `Regenerate`, or `Export executive pack` from this workspace under the current entitlements? | Review-pack generation availability, source, and blocked reason | Existing run state, artifact truth, and review status; `View` and `Download` stay outside the entitlement decision | entitlement availability, run state, artifact status | TenantPilot only until the existing generation flow starts; then existing review-pack run semantics apply | Generate pack, Export executive pack, Regenerate | Existing destructive actions remain unchanged and out of scope |
| System directory workspace entitlement summary | Platform support or operations user | Verify workspace commercial truth without switching planes | Read-only detail page | What plan and overrides are currently in effect for this workspace? | Resolved plan profile, entitlement values, source, and last changed attribution | Recent runs, tenant counts, and admin workspace link | commercial profile, entitlement source | none | Open admin workspace | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - workspace-owned plan profile and explicit entitlement override truth become current-release business truth, but they are stored in the existing workspace settings mechanism rather than a new table
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded resolver for effective entitlement decisions across multiple surfaces
- **New enum/state/reason family?**: yes - one bounded plan-profile identifier set, one bounded first-slice entitlement key catalog, and a small entitlement source vocabulary
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators and support users cannot truthfully explain whether a workspace is allowed to activate more managed tenants or generate review packs, and the product currently has no auditable commercial decision path.
- **Existing structure is insufficient because**: Generic settings storage alone does not provide a consistent runtime decision path, source attribution, usage context, or blocked-action explanation across onboarding, reporting, and system support surfaces.
- **Narrowest correct implementation**: Keep persistence inside existing workspace settings, limit the catalog to two entitlement keys, derive current usage from existing workspace and artifact truth, add one bounded resolver, and gate only two existing runtime actions in the first slice.
- **Ownership cost**: One new resolver and small catalog need ongoing tests and vocabulary review. One settings section and one system summary need ongoing UX discipline. No new tables, background workflows, or billing-provider seams are introduced.
- **Alternative intentionally rejected**: A new `Plan`, `Subscription`, or `CustomerAccount` model family was rejected because the repo has no current account or payment domain, and the first slice only needs workspace-owned runtime entitlement truth.
- **Release truth**: current-release truth with explicit follow-up candidates for later billing lifecycle work
### 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 plan-profile defaults, override merging, source attribution, and current-usage calculation. Focused feature coverage proves the existing Filament settings page, onboarding completion gate, review-pack generation gate, and system directory visibility without adding browser or heavy-governance scope.
- **New or expanded test families**: one new `Entitlements` unit family plus focused feature coverage for workspace settings, onboarding, review-pack generation, and system-directory visibility
- **Fixture / helper cost impact**: Add only workspace, membership, active managed-tenant, review-pack-capable tenant/review, and platform-user fixtures required to prove the first-slice decisions. Avoid new browser harnesses, payment-provider mocks, or broad commercial seeds.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, monitoring-state-page, shared-detail-family
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the workspace settings page and onboarding wizard. Review-pack gating also needs monitoring-state assertions to prove that blocked attempts do not create a run, while the system directory page needs one read-only platform-plane detail assertion.
- **Reviewer handoff**: Reviewers must confirm that entitlement denials are distinct from 404 and 403 RBAC outcomes, blocked review-pack actions never create an `OperationRun`, lowered limits do not mutate existing tenants or packs, and the same reason text is reused across all first-slice surfaces.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
## Scope Boundaries *(required for this slice)*
### In Scope
- One workspace-owned plan profile selected and audited through the existing workspace settings surface
- Exactly two first-slice entitlement keys:
- active managed-tenant activation limit for onboarding completion
- review-pack generation availability for existing `Generate pack`, `Regenerate`, and `Export executive pack` entry points
- Explicit workspace override values for those keys with operator-entered rationale and reset-to-default behavior
- One derived effective entitlement decision path showing value, source, rationale, and current usage where applicable
- Read-only system-plane visibility of the resolved workspace commercial truth on the existing workspace directory page
### Non-Goals
- Trial, grace, suspension, cancellation, or renewal lifecycle states
- Checkout, invoices, payment collection, taxes, proration, or billing-provider adapters
- A separate customer account, subscription, contract, or offer domain model
- Broader entitlement spread across seats, exports, retention, user counts, support SLAs, or feature flags
- Platform-plane mutation or emergency override controls in the first slice
- Customer-facing plan self-service or website pricing integration
## Assumptions
- Existing `WorkspaceSetting`, `SettingsResolver`, and `SettingsWriter` are sufficient persistence and audit primitives for the current-release commercial truth.
- The first slice may use a small code-owned plan-profile catalog because there is no existing billing or account model to import from.
- Managed-tenant activation limit is measured against the current workspace's active managed tenants and blocks future activation only; it does not retroactively deactivate existing tenants.
- Review-pack generation entitlement governs new `Generate pack`, `Regenerate`, and `Export executive pack` attempts only; existing `View` and `Download` access to already-generated artifacts continues to follow current artifact and capability rules.
## Risks
- Plan-profile naming can become prematurely productized if the catalog expands beyond the two first-slice entitlements.
- Partial gating would be misleading if one review-pack entry point enforces entitlement while another bypasses it.
- Lowering a managed-tenant limit below current usage creates an over-limit workspace that must be explained carefully so operators are not misled into expecting retroactive enforcement.
- Support users could misread the system view as a second source of truth if the admin and system surfaces drift in wording or source labels.
## Follow-up Candidates
- Customer lifecycle communication driven by plan and entitlement state
- Demo and trial readiness built on top of the same workspace commercial truth
- Broader entitlement keys for exports, retention, seats, and support-plan limits
- External billing, subscription, or contract integration once a real account domain exists
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Configure workspace commercial truth in one place (Priority: P1)
As a workspace owner or manager, I want to set the workspace plan profile and any first-slice overrides on the existing workspace settings page so later runtime behavior is predictable and attributable.
**Why this priority**: The first slice fails immediately if the product cannot define one authoritative workspace entitlement posture before runtime enforcement begins.
**Independent Test**: Open the existing workspace settings page, save a plan profile and one override with rationale, then verify that the resolved values, source, and current usage summary update without touching any onboarding or reporting surface.
**Acceptance Scenarios**:
1. **Given** a workspace manager can access workspace settings, **When** they save a plan profile with no overrides, **Then** the page shows the resolved first-slice entitlements from that profile and records the change through the existing workspace-setting audit path.
2. **Given** a workspace manager sets an explicit override for one entitlement, **When** they save the change with rationale, **Then** the resolved source changes to workspace override and the rationale is visible on the page.
3. **Given** a workspace manager resets an override, **When** the reset completes, **Then** the effective value returns to the plan-profile default and the reset is attributable in audit history.
---
### User Story 2 - Truthfully gate managed-tenant activation (Priority: P1)
As an authorized onboarding operator, I want the final onboarding step to tell me whether the workspace may activate another managed tenant and why, so I do not complete onboarding under a false assumption.
**Why this priority**: Managed-tenant activation is the highest-risk first-slice lifecycle mutation. It needs a truthful commercial gate before later trial or lifecycle specs build on top of it.
**Independent Test**: Seed workspaces under limit, at limit, and over limit, open the existing onboarding completion step, and verify that the same action is allowed or blocked with the right reason before any tenant activation mutation occurs.
**Acceptance Scenarios**:
1. **Given** a workspace is within its allowed managed-tenant limit and the actor has onboarding activation capability, **When** they reach the existing completion step, **Then** the step shows the current limit usage and allows completion.
2. **Given** a workspace is at or above its allowed managed-tenant limit, **When** the same actor reaches the completion step, **Then** the action remains visible but blocked with a truthful explanation and no tenant activation occurs.
3. **Given** the workspace has an explicit override that increases the allowed limit, **When** the actor reaches the completion step, **Then** the action uses the override value and labels the source accordingly.
---
### User Story 3 - Truthfully gate review-pack generation and expose the reason to support (Priority: P2)
As a reporting operator or platform support user, I want review-pack generation to use the same entitlement decision everywhere and be inspectable from the system directory so I can explain blocked behavior without guesswork.
**Why this priority**: Review-pack generation is an existing shared product workflow with several entry points. If it is not gated consistently, the product will immediately create conflicting commercial behavior.
**Independent Test**: Seed a workspace where review-pack generation is disabled, attempt generation from current tenant or review surfaces, confirm no new run is created, and then verify the same resolved reason on the read-only system workspace page.
**Acceptance Scenarios**:
1. **Given** review-pack generation is enabled for a workspace and the actor has `review_pack.manage`, **When** they start generation from an existing entry surface, **Then** the existing queued review-pack flow continues unchanged.
2. **Given** review-pack generation is disabled for that workspace, **When** the same actor attempts generation or regeneration from any current entry surface, **Then** the action is blocked before any `OperationRun` or `ReviewPack` is created and the reason matches the workspace entitlement decision.
3. **Given** a platform user with `platform.directory.view` opens the system workspace detail page, **When** they inspect the workspace entitlement summary, **Then** they can see the resolved plan profile, source, effective values, and last changed attribution without changing the admin-plane truth.
### Edge Cases
- A workspace with no explicit plan profile override must still resolve deterministically from the system default plan profile so operators never see an unset commercial state.
- Lowering the managed-tenant limit below the workspace's current active count must not deactivate existing tenants; it only blocks future onboarding activation and must show the workspace as over limit.
- Disabling review-pack generation must not remove access to already-generated review packs, downloads, or run history that remain allowed under existing artifact permissions.
- If review-pack generation is already queued and the workspace later becomes not entitled, existing runs may complete, but new `Generate pack`, `Regenerate`, or `Export executive pack` attempts must block from that point forward.
- A user who is a workspace member but lacks `workspace_settings.manage`, `workspace_managed_tenant.onboard.activate`, or `review_pack.manage` must still receive 403 for those actions even when the workspace itself is entitled.
- A non-member or wrong-plane actor must not learn whether a workspace is over limit or review-pack generation is disabled; those requests continue to resolve as 404.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds runtime-changing workspace-owned product truth and mutating settings writes, but it does not add Microsoft Graph calls, new provider dispatch, or a new queued workflow family. Workspace plan-profile and override changes use the existing workspace-setting audit path. Review-pack generation continues to rely on the existing `OperationRun`-backed flow only when entitlement allows the action.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces new workspace-owned commercial truth and one bounded resolver because current-release operator workflows need one consistent decision across settings, onboarding, reporting, and system support. A narrower approach would still scatter plan logic across surfaces. The feature deliberately avoids a new table, customer-account model, or billing lifecycle state machine.
**Constitution alignment (XCUT-001):** All first-slice surfaces must use the same effective entitlement decision object for value, source, rationale, and current usage. No surface may invent local blocked copy or local plan semantics.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Settings and action surfaces must show only the one commercial fact needed for the current decision. Support or audit detail remains secondary. No surface may present a neutral or success-like state when the workspace is actually blocked by entitlement or limit usage.
**Constitution alignment (PROV-001):** Plan profile, entitlement, override, and current-usage vocabulary remain platform-neutral. The feature must not introduce provider-shaped plan keys or account semantics.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes. New fixtures remain local to workspace, tenant, review-pack, and platform-directory contexts. No browser or heavy-governance family is justified for this slice.
**Constitution alignment (OPS-UX):** The feature does not add a new run family. Existing review-pack generation keeps the current queued toast, progress, and terminal notification path. Blocked review-pack attempts must not create a run, and therefore must not emit run lifecycle notifications.
**Constitution alignment (OPS-UX-START-001):** Review-pack generation entry surfaces continue delegating queued-start UX and canonical links to the shared review-pack and operation-run path. The new entitlement gate must sit before run creation rather than replacing that shared path.
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: tenant/admin `/admin` and system `/system`. Wrong-plane or non-member access remains 404. Members missing capability remain 403. Entitlement denial for an otherwise authorized actor is a product-state block, not a membership failure. Existing destructive-like actions such as onboarding draft cancellation and deletion remain confirmation-protected. Any new override reset action must also use explicit confirmation if it materially changes runtime access.
**Constitution alignment (BADGE-001):** If the slice introduces status or source badges for entitlement state, those semantics must be centralized and reused across admin and system views rather than implemented with page-local color logic.
**Constitution alignment (UI-FIL-001):** The first slice extends existing native Filament pages, widgets, actions, sections, callouts, and detail views. No custom backoffice shell or local billing panel is allowed.
**Constitution alignment (UI-NAMING-001):** Primary operator labels must stay product-facing and specific: `Plan profile`, `Managed tenant limit`, `Review pack generation`, `Override reason`, `Complete onboarding`, `Generate pack`, and `Open admin workspace`. Terms such as subscription, checkout, invoice, proration, or Stripe must not appear.
**Constitution alignment (DECIDE-001):** Workspace settings remains the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that show only the commercial truth needed for the action at hand. The system directory page remains read-only evidence.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing singleton settings, guided workflow, grouped review-pack actions, and read-only system detail patterns. It may not add redundant inspect actions, shadow settings routes, or mixed action groups that hide the blocked reason.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Settings mutation stays on the workspace settings page. Onboarding completion remains the primary action at the completion step. Review-pack generation remains the primary reporting action where already present. Navigation and diagnostics stay secondary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin decision layer is justified because direct reads from raw settings would still force each surface to rebuild merge rules, source attribution, and current usage. Tests must target business outcomes such as allowed versus blocked execution and correct source labeling, not cosmetic rendering alone.
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with documented existing exceptions for the singleton settings page and onboarding wizard. The review-pack generation entry family must keep the existing `Generate pack`, `Regenerate`, and `Export executive pack` start actions in scope, leave existing `View` and `Download` affordances outside the entitlement gate, and must not add redundant inspect affordances or placeholder action groups.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The settings changes stay inside the existing sectioned workspace settings page. Onboarding and review-pack surfaces keep the current layout and only add bounded decision truth. The system directory page remains a detail/information surface rather than a second settings panel.
### Functional Requirements
- **FR-247-001 Workspace-owned truth**: The system MUST represent first-slice commercial truth at workspace scope by storing one selected plan profile and optional explicit workspace overrides through the existing workspace settings infrastructure instead of introducing a new billing or customer-account persistence model.
- **FR-247-002 First-slice entitlement catalog**: The first slice MUST resolve exactly two entitlement keys: active managed-tenant activation limit and review-pack generation availability. No other entitlement family is required in this spec.
- **FR-247-003 Effective decision shape**: The system MUST derive an effective decision for each first-slice entitlement that includes the effective value, source, operator-visible rationale, and current usage when the entitlement is limit-based.
- **FR-247-004 Plan profile defaults**: The system MUST provide a bounded, code-owned plan-profile catalog whose defaults drive the two first-slice entitlement keys. The catalog is a product configuration artifact, not a customer contract or payment record.
- **FR-247-005 Explicit overrides**: Authorized workspace managers MUST be able to set or reset explicit override values for each first-slice entitlement together with rationale, and the resulting change MUST be attributable through the existing workspace-setting audit path.
- **FR-247-006 Workspace settings visibility**: The existing workspace settings page MUST show the current plan profile, effective first-slice entitlements, source labels, rationale, and current usage summary after save without requiring the operator to inspect code, logs, or system pages.
- **FR-247-007 Managed-tenant activation enforcement**: The existing onboarding completion action MUST consult the active managed-tenant entitlement decision before activation. If the workspace is over limit, the action MUST remain visible to otherwise authorized actors, explain why completion is blocked, and stop before tenant activation mutates runtime state.
- **FR-247-008 Review-pack generation enforcement**: All current `Generate pack`, `Regenerate`, and `Export executive pack` entry points MUST consult the same review-pack entitlement decision before creating or reusing a `ReviewPack` or `OperationRun`. If blocked, the action MUST stop before any run or artifact is created.
- **FR-247-009 Existing artifact access unchanged**: Disabling review-pack generation MUST NOT revoke view or download access to existing review packs that remain accessible under current artifact permissions.
- **FR-247-010 Over-limit behavior**: Lowering a managed-tenant limit below current active usage MUST mark the workspace as over limit for future activation attempts, but MUST NOT deactivate or archive existing tenants.
- **FR-247-011 System-plane visibility**: The existing system directory workspace page MUST show a read-only summary of the resolved plan profile, effective first-slice entitlements, source labels, and last changed attribution to platform users with `platform.directory.view`.
- **FR-247-012 Entitlement versus RBAC semantics**: Non-members and wrong-plane actors MUST continue to receive 404. Members missing the relevant capability MUST receive 403. Actors who are otherwise authorized but whose workspace is not entitled MUST receive a truthful product-state block and no silent bypass.
- **FR-247-013 No hidden commercial platform**: The first slice MUST NOT introduce checkout, invoices, payment collection, proration, trial/grace/suspension lifecycle state, customer-account records, or any external billing-provider seam.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` | N/A - singleton settings page | none | none | N/A | N/A | `Save`; per-setting `Reset` actions for plan profile and overrides | yes - existing workspace-setting update/reset audit path | Existing singleton-page exemption remains valid |
| Managed tenant onboarding completion gate | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | `Back to workspace`, `Back to onboarding`, `View tenant`, existing `Cancel draft`, existing `Delete draft` | N/A - guided workflow | none | none | existing onboarding start state remains unchanged | same header actions apply on the route-bound page | `Complete onboarding` remains confirmation-protected and now also entitlement-gated | yes - existing onboarding audit semantics remain | Existing wizard exception remains valid |
| Review-pack generation entry family | `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php` | current `Generate pack`, `Regenerate`, or `Export executive pack` entry actions stay primary | Existing clickable row remains only on the review-pack registry | existing `Download` remains the only direct row shortcut on the registry and stays outside the entitlement gate | none | existing `Generate` CTA remains on empty states where already present | existing `Download` and `Regenerate` header actions stay in place; only `Generate pack`, `Regenerate`, and `Export executive pack` are gated | N/A - action family, not a create/edit form | existing review-pack generation behavior unchanged; no new entitlement-block audit required | Grouped action family documented here so all in-scope start actions share one gate while `View` and `Download` remain unaffected |
| System directory workspace entitlement summary | `app/Filament/System/Pages/Directory/ViewWorkspace.php` | none | dedicated page route only | none | none | N/A | existing admin-workspace and runs links remain secondary navigation | N/A | no new audit action; read-only visibility only | Read-only system detail surface |
### Key Entities *(include if feature involves data)*
- **Workspace Plan Profile**: A bounded workspace-owned plan identifier that maps to the two first-slice entitlement defaults. It is not a subscription, contract, invoice, or customer account.
- **Workspace Entitlement Override**: An explicit workspace-scoped override value for one first-slice entitlement key together with operator rationale and audit attribution, persisted through the existing workspace settings stack.
- **Effective Entitlement Decision**: A derived runtime decision containing effective value, source, rationale, and current usage summary, reused by settings, onboarding, review-pack, and system visibility surfaces.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Authorized workspace managers can set or reset the first-slice commercial posture from one workspace settings page and see the resolved values, source, and current usage immediately after saving.
- **SC-002**: Authorized operators can determine from the onboarding completion step or a review-pack entry surface in under 30 seconds whether the action is allowed, blocked by plan profile, or blocked by current limit usage, without opening logs or asking support.
- **SC-003**: 100% of first-slice blocked executions stop before tenant activation or review-pack run creation and show a truthful reason instead of silently hiding the action or implying success.
- **SC-004**: Platform operators with read-only directory access can inspect the effective workspace plan profile, entitlement source, and last changed attribution from one system detail page without switching to a second source of truth.

View File

@ -1,169 +0,0 @@
---
description: "Task list for feature implementation"
---
# Tasks: Plans, Entitlements & Billing Readiness
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Align the bounded first slice, validation entry points, and repo guardrails before touching runtime code.
- [x] T001 Review the bounded slice, explicit non-goals, and guardrail outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md`
- [x] T002 [P] Review the logical route and action boundaries for workspace settings, onboarding completion, review-pack generation, and system-directory visibility in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`
- [x] T003 [P] Start or confirm the focused validation environment through `apps/platform/vendor/bin/sail` and keep the planned proof commands aligned with `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared entitlement primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add the first-slice entitlement setting keys, validation metadata, and operator-facing labels to `apps/platform/app/Support/Settings/SettingsRegistry.php`
- [x] T005 [P] Add the bounded code-owned plan profile catalog for the two first-slice entitlement defaults in `apps/platform/app/Services/Entitlements/WorkspacePlanProfileCatalog.php`
- [x] T006 Implement the shared effective entitlement decision shape, source vocabulary, rationale projection, and managed-tenant usage aggregation in `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
- [x] T007 Wire the new entitlement keys through the existing audited settings stack in `apps/platform/app/Services/Settings/SettingsResolver.php` and `apps/platform/app/Services/Settings/SettingsWriter.php`
**Checkpoint**: Foundation ready. User story work can now proceed independently.
---
## Phase 3: User Story 1 - Configure Workspace Commercial Truth In One Place (Priority: P1) 🎯 MVP
**Goal**: Let a workspace manager set one plan profile and two bounded entitlement overrides with rationale from the existing workspace settings page.
**Independent Test**: Open `/admin/settings/workspace`, save a plan profile and one override with rationale, then confirm the page shows the resolved values, source, rationale, and current usage summary without touching onboarding or review-pack surfaces.
### Tests for User Story 1
- [x] T008 [P] [US1] Add plan-profile and resolver unit coverage for default fallback, override merge, source attribution, rationale, and over-limit calculation in `apps/platform/tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php` and `apps/platform/tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php`
- [x] T009 [P] [US1] Add workspace-settings feature coverage for save, reset, rationale validation, source labels, and audit attribution in `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
### Implementation for User Story 1
- [x] T010 [US1] Extend the existing workspace settings section with a plan profile selector, two override inputs, and rationale inputs in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
- [x] T011 [US1] Add confirmation-protected override reset actions that clear both override value and rationale on `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
- [x] T012 [US1] Persist plan-profile and override writes through the existing audited settings path in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Services/Settings/SettingsWriter.php`
- [x] T013 [US1] Render the shared resolver output back onto the settings page as effective source, rationale, and current usage summary in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
**Checkpoint**: User Story 1 is independently functional and ready for focused settings validation.
---
## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1)
**Goal**: Keep the onboarding completion action visible to authorized actors while blocking activation with a truthful entitlement reason whenever the workspace is at or over its managed-tenant limit.
**Independent Test**: Seed workspaces within limit, at limit, and over limit, open the existing onboarding completion step, and confirm the action is either allowed or blocked with the correct reason before any tenant activation mutation occurs.
### Tests for User Story 2
- [x] T014 [P] [US2] Add onboarding feature coverage for within-limit, at-limit, over-limit, override-source, and 404 versus 403 versus business-state semantics in `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
### Implementation for User Story 2
- [x] T015 [US2] Project the shared managed-tenant entitlement decision onto the onboarding completion step in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T016 [US2] Enforce the managed-tenant activation entitlement before any onboarding completion mutation occurs in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T017 [US2] Keep the onboarding helper text, source labels, and business-state block logic sourced from `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php` inside `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
**Checkpoint**: User Story 2 is independently functional and preserves truthful activation gating without retroactive tenant mutation.
---
## Phase 5: User Story 3 - Truthfully Gate Review-Pack Generation And Expose The Reason To Support (Priority: P2)
**Goal**: Reuse one entitlement decision for all current `Generate pack`, `Regenerate`, and `Export executive pack` entry points, while exposing the same resolved truth read-only on the system workspace page.
**Independent Test**: Seed a workspace where review-pack generation is disabled, attempt `Generate pack`, `Regenerate`, and `Export executive pack` from the existing entry families, confirm no new `ReviewPack` or `OperationRun` is created, confirm existing `View`/`Download` access still works, and then confirm the same resolved reason on the system workspace detail page.
### Tests for User Story 3
- [x] T018 [P] [US3] Add review-pack and system-directory feature coverage for blocked `Generate pack`, `Regenerate`, and `Export executive pack` actions, preserved allowed flow, explicit `View`/`Download` no-regression behavior, no-run enforcement, and read-only entitlement visibility in `apps/platform/tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php` and `apps/platform/tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
### Implementation for User Story 3
- [x] T019 [US3] Enforce review-pack entitlement before `ReviewPack` or `OperationRun` creation for `Generate pack`, `Regenerate`, and `Export executive pack` flows while preserving existing queued-start UX and leaving `View`/`Download` behavior unchanged in `apps/platform/app/Services/ReviewPackService.php`
- [x] T020 [P] [US3] Gate the tenant dashboard review-pack card and the Review Register `Export executive pack` entry point with resolver-backed allow-or-block messaging in `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
- [x] T021 [P] [US3] Gate only the in-scope `Generate pack`, `Regenerate`, and `Export executive pack` actions on tenant review and review-pack resource surfaces with the same shared decision projection, while leaving existing `View` and `Download` access unchanged, in `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
- [x] T022 [P] [US3] Add the read-only resolved entitlement summary to the system workspace detail surface in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php`
**Checkpoint**: User Story 3 is independently functional and keeps review-pack gating and system visibility on the same decision truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish guardrail close-out, run the narrow validation commands, and format touched files without widening scope.
- [x] T023 Record the final guardrail close-out, lane result, and any bounded `document-in-feature` note for shared entitlement wording or surface exceptions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md`
- [x] T024 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php` and `apps/platform/tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
- [x] T025 Run the targeted settings and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` and `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
- [x] T026 Run the targeted review-pack and system-directory Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php` and `apps/platform/tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
- [x] T027 Run dirty-only formatting for touched platform files through `apps/platform/vendor/bin/sail` using the Pint command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after the shared settings and resolver primitives exist.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: starts after Phase 2 and does not depend on US2 or US3.
- **US2 (P1)**: starts after Phase 2 and reuses the shared resolver, but does not require US1 UI work to be complete.
- **US3 (P2)**: starts after Phase 2 and reuses the shared resolver plus settings keys, but does not require onboarding work to be complete.
### Within Each User Story
- Write the listed Pest tests first and make them fail for the intended behavior gap.
- Complete shared service enforcement before wiring multiple entry points that depend on it.
- Keep each story shippable on its own before moving to the next story.
---
## Parallel Execution Examples
### User Story 1
- Run T008 and T009 in parallel.
- After T010 starts the settings section, keep T011 and T012 coordinated because both touch `WorkspaceSettings.php`.
### User Story 2
- Run T014 in parallel with any remaining US1 validation work after Phase 2 is complete.
- Keep T015, T016, and T017 sequential because they all tighten the same onboarding completion boundary.
### User Story 3
- Run T018 first, then complete T019 before splitting T020, T021, and T022 across separate files.
- T020, T021, and T022 can proceed in parallel once the service-level gate in `ReviewPackService.php` is in place.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **User Story 1** only. It establishes the workspace-owned plan profile, two entitlement keys, explicit overrides with rationale, and the shared decision truth that every later gate depends on.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the settings-backed commercial truth.
3. Deliver US2 and validate onboarding activation gating.
4. Deliver US3 and validate review-pack gating plus read-only system visibility.
5. Finish with the Phase 6 guardrail close-out, focused validation commands, and formatting.

View File

@ -1,57 +0,0 @@
# Specification Quality Checklist: Private AI Execution & Policy Foundation
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Business value and operator outcomes stay explicit
- [x] The first slice is bounded to one governed decision boundary, two approved internal-only use cases, one workspace AI policy section, and one reused operational control
- [x] Runtime-governance sections are present for an implementation-ready package, not treated as docs-only
- [x] All mandatory sections are completed
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Acceptance scenarios are defined for workspace policy, governed allow-or-block decisions, and central pause/resume handling
- [x] Edge cases are identified, including missing workspace context, unregistered use cases, blocked data classes, and active `ai.execution` control
- [x] Scope is clearly bounded away from customer-facing AI, external public-provider execution, queue or `OperationRun` work, and prompt or result persistence
- [x] Dependencies, assumptions, risks, and follow-up candidates are identified
## Feature Readiness
- [x] The first slice is small enough for a bounded implementation loop
- [x] Concrete repo surfaces are named for workspace settings, system ops controls, audit reuse, and the new in-process AI support namespace
- [x] Foundational work stays preparation-only and does not imply model runtime, customer UI, or a new AI table or result store
- [x] The tasks are ordered, testable, and grouped by user story
- [x] No unresolved product question blocks `/speckit.implement` once artifact analysis passes
## Governance Readiness
- [x] Workspace-owned AI policy truth is explicitly kept in existing settings persistence with no new AI table or result ledger
- [x] The approved-use-case catalog remains locked to two internal-only consumers and keeps provider vocabulary vendor-neutral
- [x] The package explicitly forbids customer-facing AI, external public-provider execution, and queue or `OperationRun` semantics in v1
- [x] Existing workspace and platform authorization paths remain authoritative, with confirmation-protected `Pause AI execution` and `Resume AI execution` as the only destructive-like mutations in scope
- [x] Livewire v4 and Filament v5 compliance, unchanged provider registration in `bootstrap/providers.php`, no new global-search resource, and no asset-strategy changes are explicit in the package
## Test Governance Review
- [x] Lane fit stays in focused unit plus feature validation with one architecture guard only
- [x] Fixture and helper growth stays local to AI support, workspace settings, operational controls, and guard coverage
- [x] No browser, heavy-governance, queue, or provider-emulator family is introduced implicitly
- [x] Minimal validation commands are explicit in the plan and quickstart
- [x] The active feature PR close-out entry remains `Guardrail`
## Review Outcome
- [x] Review outcome class: `keep`
- [x] Workflow outcome: `keep`
- [x] Next command readiness: `/speckit.implement` after artifact analysis is clear
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, supporting artifacts, and `tasks.md`. It does not claim that application code or an AI execution runtime already exists.
- The active slice stops before customer-facing AI, external-public provider execution, queue or `OperationRun` orchestration, prompt or result persistence, and any broader provider marketplace or budgeting work.
- Provider registration remains unchanged in `bootstrap/providers.php`, no new global-search resource is introduced, and no new asset strategy is needed for this package.

View File

@ -1,277 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot AI Governance Foundation (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the existing workspace settings page, the existing
system operational-controls page, and the in-process governed AI decision
schema planned by Spec 248.
NOTE: The settings and controls actions are implemented as existing Filament
(Livewire) pages/actions. No new customer-facing AI route or external
provider execution endpoint is introduced in v1.
servers:
- url: /
paths:
/admin/settings/workspace:
get:
summary: View workspace settings page
description: |
Existing singleton workspace settings route.
The AI policy section is planned to render on this page without adding a
second AI admin surface.
responses:
'200':
description: Workspace settings page rendered
content:
text/html:
schema:
type: string
'404':
description: Not found (wrong workspace or non-member)
'403':
description: Forbidden (member without view capability)
/admin/settings/workspace/ai-policy:
post:
summary: Save workspace AI policy
description: |
Logical action on the existing Filament workspace settings page.
Non-members or wrong-workspace actors receive 404 semantics before any
policy detail is revealed. Members without
`workspace_settings.manage` receive 403 on mutation.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [policy_mode]
properties:
policy_mode:
$ref: '#/components/schemas/WorkspaceAiPolicyMode'
responses:
'204':
description: Policy saved
'403':
description: Forbidden (member lacks manage capability)
'404':
description: Not found (wrong workspace or non-member)
/system/ops/controls:
get:
summary: View system operational controls page
description: |
Existing system control-center route. The AI execution control is added
here rather than on a new AI console. Wrong-plane or non-platform
actors keep deny-as-not-found semantics before any system control detail
is revealed.
responses:
'200':
description: Controls page rendered
content:
text/html:
schema:
type: string
'404':
description: Not found (wrong plane or non-platform actor)
'403':
description: Forbidden (platform actor lacks required system capability)
/system/ops/controls/ai.execution/pause:
post:
summary: Pause AI execution globally
description: |
Logical control action on the existing system controls page.
Wrong-plane or non-platform actors receive 404 semantics before any
control detail is revealed.
Must require confirmation in the UI and enforce
`platform.access_system_panel` plus `platform.ops.controls.manage`
server-side. Spec 248 keeps `ai.execution` global-only in v1.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reason_text]
properties:
reason_text:
type: string
expires_at:
type: string
format: date-time
nullable: true
responses:
'204':
description: Control activated
'404':
description: Not found (wrong plane or non-platform actor)
'403':
description: Forbidden (platform actor lacks required control capability)
/system/ops/controls/ai.execution/resume:
post:
summary: Resume AI execution globally
description: |
Logical control action on the existing system controls page.
Wrong-plane or non-platform actors receive 404 semantics before any
control detail is revealed.
Removes an active `ai.execution` pause using the existing control-center
confirmation and audit flow. Spec 248 keeps `ai.execution`
global-only in v1.
responses:
'204':
description: Control resumed
'404':
description: Not found (wrong plane or non-platform actor)
'403':
description: Forbidden (platform actor lacks required control capability)
components:
schemas:
WorkspaceAiPolicyMode:
type: string
enum: [disabled, private_only]
ProviderClass:
type: string
enum: [local_private, external_public]
AiDataClassification:
type: string
enum:
- product_knowledge
- operational_metadata
- redacted_support_summary
- personal_data
- customer_confidential
- raw_provider_payload
ApprovedAiUseCaseKey:
type: string
enum:
- product_knowledge.answer_draft
- support_diagnostics.summary_draft
GovernedAiExecutionRequest:
type: object
description: |
In-process service contract, not a public HTTP endpoint in v1.
This is the preflight envelope evaluated before any provider resolution
or model execution is attempted. The host surface must already have
resolved authorization and scope entitlement before this request is
constructed.
required:
- workspace_id
- actor_type
- actor_id
- use_case_key
- requested_provider_class
- data_classifications
- source_family
properties:
workspace_id:
type: integer
tenant_id:
type: integer
nullable: true
actor_type:
type: string
actor_id:
type: integer
use_case_key:
$ref: '#/components/schemas/ApprovedAiUseCaseKey'
requested_provider_class:
$ref: '#/components/schemas/ProviderClass'
data_classifications:
type: array
items:
$ref: '#/components/schemas/AiDataClassification'
source_family:
type: string
caller_surface:
type: string
nullable: true
context_fingerprint:
type: string
nullable: true
GovernedAiExecutionDecision:
type: object
required:
- outcome
- reason_code
- workspace_ai_policy_mode
- use_case_key
- requested_provider_class
- data_classifications
- source_family
properties:
outcome:
type: string
enum: [allowed, blocked]
reason_code:
type: string
workspace_ai_policy_mode:
$ref: '#/components/schemas/WorkspaceAiPolicyMode'
matched_operational_control_scope:
type: string
enum: [global]
nullable: true
use_case_key:
$ref: '#/components/schemas/ApprovedAiUseCaseKey'
requested_provider_class:
$ref: '#/components/schemas/ProviderClass'
data_classifications:
type: array
items:
$ref: '#/components/schemas/AiDataClassification'
source_family:
type: string
audit_action:
type: string
audit_metadata:
$ref: '#/components/schemas/AiDecisionAuditMetadata'
AiDecisionAuditMetadata:
type: object
required:
- use_case_key
- decision_outcome
- decision_reason
- workspace_ai_policy_mode
- requested_provider_class
- data_classifications
- source_family
- workspace_id
properties:
use_case_key:
$ref: '#/components/schemas/ApprovedAiUseCaseKey'
decision_outcome:
type: string
enum: [allowed, blocked]
decision_reason:
type: string
workspace_ai_policy_mode:
$ref: '#/components/schemas/WorkspaceAiPolicyMode'
requested_provider_class:
$ref: '#/components/schemas/ProviderClass'
data_classifications:
type: array
items:
$ref: '#/components/schemas/AiDataClassification'
source_family:
type: string
workspace_id:
type: integer
tenant_id:
type: integer
nullable: true
context_fingerprint:
type: string
nullable: true
matched_operational_control_scope:
type: string
enum: [global]
nullable: true

View File

@ -1,209 +0,0 @@
# Data Model — Private AI Execution & Policy Foundation
**Spec**: [spec.md](spec.md)
No new persistent tables or AI artifact stores are required for v1. The feature reuses existing workspace settings, operational controls, and audit logs. New AI-specific structures are code-owned or request-scoped.
## Persisted Truth Reused
### Workspace AI Policy (`workspace_settings` carrier)
**Purpose**: Workspace-owned policy truth that determines whether AI is disabled entirely or limited to approved private-only use cases.
**Persisted carrier**: existing `workspace_settings` row via `WorkspaceSetting`
**Planned definition**:
- `domain`: `ai`
- `key`: `policy_mode`
- `type`: `string`
- `system_default`: `disabled`
- `allowed values`: `disabled`, `private_only`
- `scope`: workspace only; no tenant override in v1
**Validation rules**:
- required
- string
- `in:disabled,private_only`
**Authorization**:
- view: existing `workspace_settings.view`
- mutation: existing `workspace_settings.manage`
**Audit strategy**:
- reuse `workspace_setting.updated` and `workspace_setting.reset`
- include AI-specific metadata in the existing workspace-settings audit context
**State transitions**:
- `disabled` -> `private_only`
- `private_only` -> `disabled`
### AI Execution Control (`operational_control_activations` carrier)
**Purpose**: Platform-owned runtime stop for new AI execution attempts.
**Persisted carrier**: existing `OperationalControlActivation`
**Planned definition**:
- `control_key`: `ai.execution`
- `label`: `AI execution`
- `supported_scopes`: `global`
- `affected_surfaces`: governed AI decision callers only
**Behavior**:
- a matching active control blocks new AI execution decisions before provider resolution
- global pause is the required v1 incident path
- workspace-specific pause or tenant-specific pause is out of scope for v1 and remains a follow-up concern if future incident handling genuinely requires it
**State transitions**:
- `enabled` -> `paused`
- `paused` -> `enabled`
### AI Decision Audit (`audit_logs` carrier)
**Purpose**: Stable record of governed AI allow/block evaluations without storing raw prompt or output content.
**Persisted carrier**: existing `audit_logs` rows through `WorkspaceAuditLogger` / `AuditRecorder`
**Planned action strategy**:
- reuse existing workspace-setting actions for policy mutation
- add one bounded AI decision action ID, e.g. `ai_execution.decision_evaluated`, for governed decision evaluations
**Planned metadata**:
- `use_case_key`
- `decision_outcome` (`allowed` or `blocked`)
- `decision_reason`
- `workspace_ai_policy_mode`
- `requested_provider_class`
- `data_classifications`
- `source_family`
- `workspace_id`
- optional `tenant_id`
- optional `context_fingerprint`
- optional `matched_operational_control_scope`
**Explicit exclusions**:
- raw prompt text
- raw source payloads
- raw provider payloads
- full model output text
## Code-Owned Truth
### Approved AI Use Case Definition
**Purpose**: Code-owned allowlist entry that defines one approved AI purpose and its trust constraints.
**Fields**:
- `key`
- `future_consumer`
- `visibility`
- `allowed_provider_classes`
- `allowed_data_classifications`
- `source_family`
- `tenant_context_permitted`
**v1 catalog is locked to exactly two entries**:
| Key | Future Consumer | Visibility | Allowed Provider Classes | Allowed Data Classifications | Source Family | Tenant Context Permitted |
|---|---|---|---|---|---|---|
| `product_knowledge.answer_draft` | `ContextualHelpResolver` and related code-owned knowledge sources | `internal_only_draft` | `local_private` | `product_knowledge`, `operational_metadata` | `product_knowledge` | no |
| `support_diagnostics.summary_draft` | redacted summary derived from `SupportDiagnosticBundleBuilder` | `internal_only_draft` | `local_private` | `redacted_support_summary` | `support_diagnostics` | yes |
**Validation rules**:
- key must be registered in the catalog
- no third use case may appear in v1 without a spec update
- `external_public` is never allowed for these entries in v1
### Provider Class
**Purpose**: Vendor-neutral trust boundary for AI routing decisions.
**Allowed values**:
- `local_private`
- `external_public`
**Behavioral consequence**:
- `external_public` is always blocked in v1
- `local_private` may be allowed only when the use case and data classifications permit it
### AI Data Classification
**Purpose**: Declarative label that determines whether a data family may cross the governed AI boundary.
**Values**:
- `product_knowledge`
- `operational_metadata`
- `redacted_support_summary`
- `personal_data`
- `customer_confidential`
- `raw_provider_payload`
**Behavioral consequence**:
- `personal_data`, `customer_confidential`, and `raw_provider_payload` are always blocked in v1
- allowed classifications vary by use case
## Request-Scoped Contracts
### AI Execution Request
**Purpose**: In-process request envelope passed to the governed decision boundary before any provider resolution or model execution is attempted.
**Fields**:
- `workspace_id`
- optional `tenant_id`
- `actor_type`
- `actor_id`
- `use_case_key`
- `requested_provider_class`
- `data_classifications` (list)
- `source_family`
- optional `caller_surface`
- optional `context_fingerprint`
**Validation rules**:
- `workspace_id` is required
- `use_case_key` must be registered
- `requested_provider_class` must be declared by the registered use case
- every declared data classification must be allowed for the use case
- host-surface authorization must already be resolved before evaluation
**Important v1 boundary**:
- the request is a preflight contract and does not need to carry raw prompt or payload text in v1
- future runtime/provider work can extend around this envelope later, but not inside this spec
### AI Execution Decision
**Purpose**: Terminal allow/block result returned by the governed boundary.
**Fields**:
- `outcome` (`allowed` or `blocked`)
- `reason_code`
- `workspace_ai_policy_mode`
- `matched_operational_control_scope` (nullable)
- `use_case_key`
- `requested_provider_class`
- `data_classifications`
- `source_family`
- `audit_action`
- `audit_metadata`
**Behavioral consequence**:
- `blocked`: provider resolution must not occur
- `allowed`: returns an approved handoff envelope only; v1 still does not execute a provider call or create a persisted result
## State Transitions Summary
### Workspace AI Policy
- `disabled` <-> `private_only`
### Operational Control
- `enabled` <-> `paused`
### AI Execution Decision
- `evaluating` -> `allowed`
- `evaluating` -> `blocked`
There is no queued, running, retrying, completed, or persisted-result lifecycle in v1.

View File

@ -1,282 +0,0 @@
# Implementation Plan: Private AI Execution & Policy Foundation
**Branch**: `248-private-ai-policy-foundation` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce a narrow AI governance foundation inside the existing Laravel monolith by reusing the workspace settings page for workspace-owned AI posture, reusing the system operational-controls page for a global `ai.execution` stop, and adding one in-process governed AI decision boundary plus a code-owned allowlist for exactly two internal-only use cases. Host-surface authorization remains a precondition; the AI boundary begins only after caller-side entitlement has already succeeded. The first slice is a preflight allow/block contract with audit-ready metadata, not a customer-facing AI workflow and not a model-provider runtime.
Filament v5 remains on Livewire v4, no panel-provider registration changes are needed (`bootstrap/providers.php` remains the authoritative registration location), no new globally searchable AI resource is introduced, and no new panel-only asset bundle is expected for v1.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing Settings/Audit/OperationalControls support services
**Storage**: PostgreSQL via existing `workspace_settings`, `operational_control_activations`, and `audit_logs` persistence; no new AI tables
**Testing**: Pest v4 (PHPUnit 12 runner), narrow unit + feature + architecture-guard coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform` running via Sail; admin `/admin` and platform `/system` panels
**Project Type**: Web application (Laravel monolith with Filament panels)
**Performance Goals**: decision evaluation remains synchronous and DB-only in v1; no outbound provider call or queue handoff is required to compute allow/block
**Constraints**: no direct external provider calls with tenant data; no `OperationRun`; no result or prompt persistence; reuse existing workspace settings and ops controls; keep `/admin` and `/system` auth planes separate; no new asset bundle or second AI admin surface
**Scale/Scope**: 2 approved use cases, 2 policy modes, 2 provider classes, 6 data classifications, 2 existing operator surfaces, 1 new governed in-process decision seam
## UI / Surface Guardrail Plan
> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.**
- **Guardrail scope**: changed surfaces on the existing workspace settings and system operational-controls pages
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: workspace settings, operational safety controls, audit/status copy
- **State layers in scope**: page
- **Audience modes in scope**: operator-MSP, operator-platform, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first; diagnostics remain secondary on the control history path; no support-raw surface is introduced in v1
- **Raw/support gating plan**: collapsed; raw prompt, source, and provider payload detail are excluded from the slice entirely
- **One-primary-action / duplicate-truth control**: workspace settings keep `Save` as the single primary mutation action; the system controls card keeps `Pause AI execution` / `Resume AI execution`; workspace policy truth and runtime-stop truth stay on separate surfaces
- **Handling modes by drift class or surface**: review-mandatory; any extra AI page, direct `Run AI` action, or evidence viewer is exception-required
- **Repository-signal treatment**: review-mandatory now, future hard-stop candidate once the no-direct-provider guard exists
- **Special surface test profiles**: standard-native-filament
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none; v1 remains inside the two existing pages
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
- **Cross-cutting feature marker**: yes
- **Systems touched**: `WorkspaceSettings`, `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, `Controls`, `OperationalControlCatalog`, `OperationalControlEvaluator`, `AuditActionId`, `AuditRecorder`, `WorkspaceAuditLogger`, `ContextualHelpResolver`, and `SupportDiagnosticBundleBuilder`
- **Shared abstractions reused**: existing workspace settings persistence + audit flow, existing operational-control evaluator/catalog, existing audit recorder/logger pipeline, existing product-knowledge resolver, and existing support-diagnostics bundle builder path
- **New abstraction introduced? why?**: one in-process governed AI decision boundary and one code-owned use-case catalog, because the current shared settings/ops/audit services do not own AI allow/block semantics
- **Why the existing abstraction was sufficient or insufficient**: settings, ops controls, and audit are already sufficient for persistence, emergency stop, and logging; they are insufficient for AI decision evaluation because the repo currently has no app-level AI seam at all
- **Bounded deviation / spread control**: none; future callers must depend on the new boundary rather than page-local AI helpers
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: initiation remains on the existing settings and controls pages only; no queued start UX is introduced
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: none in v1; no vendor adapters, credentials, or model-selection UI are introduced
- **Platform-core seams**: AI use-case key, provider class, data classification, workspace AI policy, and governed decision contract
- **Neutral platform terms / contracts preserved**: `AI use case`, `provider class`, `data classification`, `source family`, `workspace AI policy`, and `execution decision`
- **Retained provider-specific semantics and why**: none; `local_private` and `external_public` are trust classes, not vendor names
- **Bounded extraction or follow-up path**: follow-up-spec for provider integration and usage governance; do not widen inside v1
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: N/A. This slice adds no inventory or backup truth and does not change the Intune source-of-truth model.
- Read/write separation: PASS. Workspace policy writes stay on the existing settings flow, and pause/resume actions stay on the existing controls flow with confirmation + audit.
- Graph contract path: PASS. No Microsoft Graph contract or outbound provider call is introduced.
- Deterministic capabilities: PASS. Reuses `Capabilities::WORKSPACE_SETTINGS_VIEW`, `Capabilities::WORKSPACE_SETTINGS_MANAGE`, `PlatformCapabilities::ACCESS_SYSTEM_PANEL`, and `PlatformCapabilities::OPS_CONTROLS_MANAGE`; no raw capability strings are planned.
- Workspace isolation + tenant isolation: PASS. AI decision requests require a host surface that already resolved workspace context and optional tenant entitlement; the boundary does not become a cross-tenant shortcut.
- RBAC-UX plane separation: PASS. `/admin/settings/workspace` stays tenant-plane/workspace-scoped, `/system/ops/controls` stays platform-scoped, and wrong-plane access remains outside scope.
- Destructive confirmation standard: PASS. `Pause AI execution` and `Resume AI execution` remain confirmation-protected actions on the existing controls page.
- Global search safety: PASS / N/A. No new Resource, Global Search entry, or tenantless AI list is introduced.
- OperationRun and Ops-UX: PASS by non-use. This slice creates no `OperationRun`, queue, notification lifecycle, or Monitoring link.
- Data minimization: PASS. Audit stores decision metadata only; raw prompt, source payload, and output text remain excluded.
- Test governance (TEST-GOV-001): PASS. Proof stays in narrow unit + feature + architecture-guard coverage; no browser or heavy-governance family is required by default.
- Proportionality / no premature abstraction: PASS with bounded exception. One governed AI boundary and one bounded use-case catalog are justified by two concrete future consumers and safety needs; no provider marketplace, queue pipeline, or persistence layer is introduced.
- Persisted truth (PERSIST-001): PASS. Workspace AI policy reuses existing workspace settings; no AI table, cache, result store, or prompt ledger is added.
- Behavioral state (STATE-001): PASS. `disabled` and `private_only` directly change execution eligibility; provider classes and data classifications directly change allow/block behavior.
- Shared pattern first / UI semantics / Filament native UI: PASS. Existing settings, controls, and audit primitives are reused; no custom AI shell, second status framework, or duplicate truth surface is introduced.
- Provider boundary (PROV-001): PASS. Shared terms stay vendor-neutral (`provider class`, `data classification`, `AI use case`), and direct provider-specific seams are deferred.
- Filament/Laravel panel safety: PASS. Livewire v4 remains the Filament v5 runtime, `SystemPanelProvider` stays on the existing `/system` panel, and no provider-registration change beyond `bootstrap/providers.php` is needed.
- Asset strategy: PASS. No new panel-only or shared asset registration is planned; deployment keeps the normal `cd apps/platform && php artisan filament:assets` step if implementation later registers assets.
**Gate evaluation**: PASS (no constitution violation is required to deliver the narrow v1 slice).
- The governed boundary is an in-process decision seam only; it does not create provider execution, queueing, or result persistence.
- Workspace policy truth stays inside the existing settings stack and reuses existing audit behavior.
- The system kill switch reuses the existing operational-control evaluator and controls page rather than creating a second AI control surface.
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/private-ai-governance.openapi.yaml](contracts/private-ai-governance.openapi.yaml)).
## Test Governance Check
> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.**
- **Test purpose / classification by changed surface**: Unit for the catalog, request/decision contract, operational-control precedence, and audit metadata shaping; Feature for the workspace settings and system controls surfaces; Feature/Guard for the no-direct-provider invariant
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves the decision matrix without Filament boot cost, feature coverage proves the two existing operator surfaces plus authorization/audit integration, and one architecture guard protects against local provider bypasses; browser and heavy-governance coverage add cost without proving new business truth
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiUseCaseCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoDirectAiProviderBypassTest.php`
- **Fixture / helper / factory / seed / context cost risks**: low-to-moderate; reuse existing workspace settings, membership, platform-user, and operational-control fixtures, but avoid browser harnesses, provider emulators, or seeded AI history
- **Expensive defaults or shared helper growth introduced?**: no; the AI boundary should accept simple value objects/arrays, and feature tests should avoid broad `WorkspaceSettingsManageTest.php` workflow setup unless an implementation change genuinely needs that depth
- **Heavy-family additions, promotions, or visibility changes**: none expected; do not promote this slice into browser or heavy-governance families by default
- **Surface-class relief / special coverage rule**: standard-native-filament relief for the two existing pages, plus one direct service-level rule that blocked requests produce no provider resolution
- **Closing validation and reviewer handoff**: rerun the twelve focused test commands above, verify that `ai.execution` uses the existing operational-control path, verify that workspace policy changes still reuse the existing settings authorization and audit behavior, and verify that no app-level AI provider client exists outside the governed boundary
- **Budget / baseline / trend follow-up**: none expected; if workspace settings coverage broadens into the existing heavy-governance family, document the lane cost in-feature rather than hiding it
- **Review-stop questions**: lane fit, breadth, hidden setup cost, architecture-guard coverage, accidental provider/runtime scope growth
- **Escalation path**: `document-in-feature` for contained lane drift; `reject-or-split` if implementation introduces browser/heavy-governance cost, queue semantics, or provider integration
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: routine narrow test upkeep stays inside this feature; broader AI runtime and provider workflows are already deferred to follow-up candidates
## Project Structure
### Documentation (this feature)
```text
specs/248-private-ai-policy-foundation/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── private-ai-governance.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/Pages/Settings/WorkspaceSettings.php
│ ├── Filament/System/Pages/Ops/Controls.php
│ ├── Providers/Filament/SystemPanelProvider.php
│ ├── Services/Audit/
│ │ ├── AuditRecorder.php
│ │ └── WorkspaceAuditLogger.php
│ ├── Services/Settings/
│ │ ├── SettingsResolver.php
│ │ └── SettingsWriter.php
│ ├── Support/Audit/AuditActionId.php
│ ├── Support/Auth/
│ │ ├── Capabilities.php
│ │ └── PlatformCapabilities.php
│ ├── Support/OperationalControls/
│ │ ├── OperationalControlCatalog.php
│ │ └── OperationalControlEvaluator.php
│ ├── Support/ProductKnowledge/ContextualHelpResolver.php
│ ├── Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php
│ └── Support/Ai/ # likely new narrow namespace if implementation proceeds
└── tests/
├── Feature/SettingsFoundation/
├── Feature/OperationalControls/
├── Feature/System/OpsControls/
├── Feature/Guards/
├── Unit/Support/OperationalControls/
├── Unit/Support/ProductKnowledge/
└── Unit/Support/Ai/
```
**Structure Decision**: Laravel monolith. Implementation stays entirely inside `apps/platform`, reusing existing settings, audit, and operational-control seams while adding only one narrow AI support namespace if code work later proceeds.
## Complexity Tracking
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| BLOAT-001 — governed AI decision boundary | One central allow/block seam is the smallest safe place to enforce workspace policy, operational controls, provider class gating, and audit metadata before any future AI caller can reach a model | Per-surface AI helpers would duplicate policy/control/audit logic and create bypass risk across product knowledge and diagnostics |
| BLOAT-001 — code-owned AI use-case catalog | Two concrete future adopters need a single allowlist and stable vocabulary now | Free-form string keys spread across callers would drift and be difficult to guard or audit consistently |
| STATE-001 — AI policy / provider / data-classification families | These values directly change whether execution is allowed and what may cross the trust boundary | Vendor names or presentation-only labels would not be enforceable, portable, or sufficiently reviewable |
## Proportionality Review
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
- **Current operator problem**: TenantPilot has no safe app-level AI seam today, so future AI work would otherwise begin as local provider calls and local prompt/policy logic that bypass workspace isolation, runtime controls, and auditability.
- **Existing structure is insufficient because**: the repo already has settings, operational controls, and audit infrastructure, but it has no place to classify AI use cases, provider trust classes, or data classifications, and no single decision service that every caller must use.
- **Narrowest correct implementation**: add one workspace setting (`ai.policy_mode`), one operational control key (`ai.execution`), one code-owned use-case catalog for exactly two internal-only consumers, one request/decision contract, and one audit metadata shape. Do not add provider adapters, queue semantics, result persistence, or customer-visible AI surfaces.
- **Ownership cost created**: maintain 2 use-case entries, 2 policy values, 2 provider classes, 6 data classifications, one bounded audit action/metadata shape, and one architecture guard.
- **Alternative intentionally rejected**: local AI helpers on each future surface and a broader multi-provider AI platform were both rejected because they either create safety drift or import speculative architecture before the first real runtime need exists.
- **Release truth**: current-release governance foundation and future-feature preflight seam; not a full AI execution product.
## Phase 0 — Research (output: research.md)
Research resolved the remaining implementation-shaping decisions:
- Reuse `WorkspaceSettings` plus `SettingsRegistry` / `SettingsWriter` for workspace-owned AI policy truth.
- Reuse `OperationalControlCatalog` / `OperationalControlEvaluator` and the existing `Controls` page for `ai.execution` rather than creating a second AI control surface.
- Model v1 as a governed decision boundary, not a provider runtime, queue, or result store.
- Lock the first slice to two code-owned internal use cases tied to `ContextualHelpResolver` and the support-diagnostics bundle path.
- Reuse existing audit infrastructure and keep the AI audit family minimal.
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow implementation shape:
- Existing persisted truth reused: `workspace_settings`, `operational_control_activations`, and `audit_logs`.
- New code-owned truth: AI policy mode, provider class, data classification, approved use-case definitions, and request/decision envelopes.
- Conceptual contracts cover the existing workspace settings page, the existing system controls page, and the in-process governed decision schema.
- Quickstart documents the intended slice order, validation commands, Filament/Livewire assumptions, and the no-new-assets posture.
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/private-ai-governance.openapi.yaml](contracts/private-ai-governance.openapi.yaml)
- [quickstart.md](quickstart.md)
## Phase 2 — Planning (for tasks.md)
Dependency-ordered implementation outline for the later `tasks.md` step:
1. Extend the existing settings registry and workspace settings page with `ai.policy_mode` and plain-language explanation content, without broadening the singleton settings workflow.
2. Add `ai.execution` to the operational-control catalog and controls page, keeping pause/resume confirmation-protected and audit-backed.
3. Introduce a narrow `Support/Ai` namespace containing the use-case catalog, request/decision value objects, and the governed decision boundary only.
4. Reuse the existing audit pipeline for workspace policy mutations and add one bounded AI decision action/metadata shape for allow/block evaluations.
5. Name `ContextualHelpResolver` and `SupportDiagnosticBundleBuilder` as the first adopters, but do not ship customer-facing AI UI, model-provider runtime code, or direct caller wiring beyond what the boundary contract itself requires.
6. Add focused unit, feature, and architecture-guard tests while keeping browser and heavy-governance families out of scope by default.
7. Run focused tests and Pint after implementation; no asset build is expected unless implementation later registers Filament assets.
## Post-Implementation Close-Out
- **Implementation status**: Implemented and validated on 2026-04-27.
- **TEST-GOV-001 outcome**: PASS. Proof stayed in focused Pest `Unit` and `Feature` lanes plus one architecture guard, with no browser or heavy-governance suite expansion.
- **Executed validation summary**:
- AI boundary unit lane: 8 tests, 83 assertions passed.
- AI execution controls feature lane: 1 test, 34 assertions passed.
- Operational controls regression lane: 11 tests, 167 assertions passed.
- Workspace settings lane: 20 tests, 267 assertions passed.
- Platform authorization semantics lane: 6 tests, 26 assertions passed.
- No-direct-provider guard lane: 1 test, 1 assertion passed.
- Approved source-input lane: 2 tests, 30 assertions passed.
- Adjacent product-knowledge/support-diagnostics regression lane: 14 tests, 107 assertions passed.
- Final targeted feature validation rollup: 42 tests, 530 assertions passed.
- Formatting: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- **Catalog lock and tenant-context declaration**:
- `product_knowledge.answer_draft`: `tenant_context_permitted = false`
- `support_diagnostics.summary_draft`: `tenant_context_permitted = true`
- Boundary coverage plus the approved source adapters preserved that split.
- **Browser smoke result**: PASS.
- `/admin/settings/workspace`: authenticated as a workspace manager, changed `Workspace AI policy` from the default effective disabled state to `Private only`, saved successfully, and confirmed the effective summary plus approved-use-case/provider-class copy updated on the real page.
- `/system/ops/controls`: authenticated as a platform operator, opened the `AI execution` card, paused execution with confirmation and reason text, confirmed the `Paused globally` state and success notification, then resumed execution and confirmed the enabled state returned.
- **Environment note**: the integrated browser carried a stale or poisoned `localhost` system-panel session during smoke work. The product routes themselves were healthy; the system-panel smoke path completed successfully on `127.0.0.1` to get a clean host-scoped browser session. This was an environment/browser-session workaround, not a feature bug.
- **Guardrail close-out**: no confirmed in-scope findings remained after the code, validation, browser smoke, and artifact analysis loop. No new provider runtime, queue, result persistence, or customer-facing AI surface was introduced.
- **Follow-up-spec deferrals retained**:
- public or external-provider execution
- result persistence, cache, or prompt/output history
- AI budgeting, credits, or cost controls
- queued AI execution or `OperationRun` semantics
- customer-facing AI workflows or approval flows

View File

@ -1,76 +0,0 @@
# Quickstart — Private AI Execution & Policy Foundation
## Preconditions
- Docker is running.
- `apps/platform` dependencies are installed.
- This slice stays inside the existing Laravel / Filament runtime and does not introduce a second AI service.
## Intended Implementation Order
1. Add `ai.policy_mode` to the existing settings registry and workspace settings page.
2. Add `ai.execution` to the existing operational-control catalog and controls page.
3. Add a narrow `app/Support/Ai/` namespace containing the use-case catalog, request/decision value objects, and the governed decision boundary only.
4. Reuse the existing audit pipeline for workspace policy mutation and AI decision logging.
5. Add the no-direct-provider architecture guard and the focused unit/feature tests.
## Targeted Validation Commands (after implementation)
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiUseCaseCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoDirectAiProviderBypassTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Manual Smoke (after implementation)
1. Sign in to `/admin`, select a workspace, and open `/admin/settings/workspace`.
2. As a workspace manager, switch the AI policy between `Disabled` and `Private only` and confirm the page shows the allowed use cases, provider classes, and blocked data classes in plain language.
3. Sign in to `/system` as a platform operator with `platform.access_system_panel` and `platform.ops.controls.manage`, then open `/system/ops/controls`.
4. Pause `AI execution`, confirm the global reason/expiry flow, and verify that the control state is visible before resuming it.
5. Exercise the governed AI boundary through focused tests or a narrow internal stub caller only; no customer-facing AI route or UI is part of v1.
## Implementation Outcome (2026-04-27)
- `TEST-GOV-001`: PASS.
- Focused validation stayed in Pest `Unit` plus `Feature` lanes with one architecture guard only.
- Executed validation summary:
- AI boundary unit lane: 8 tests, 83 assertions passed.
- AI execution controls feature lane: 1 test, 34 assertions passed.
- Operational controls regression lane: 11 tests, 167 assertions passed.
- Workspace settings lane: 20 tests, 267 assertions passed.
- Platform authorization semantics lane: 6 tests, 26 assertions passed.
- No-direct-provider guard lane: 1 test, 1 assertion passed.
- Approved source-input lane: 2 tests, 30 assertions passed.
- Adjacent product-knowledge/support-diagnostics regression lane: 14 tests, 107 assertions passed.
- Final targeted feature validation rollup: 42 tests, 530 assertions passed.
- Pint: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- Catalog lock and tenant-context declaration:
- `product_knowledge.answer_draft`: `tenant_context_permitted = false`
- `support_diagnostics.summary_draft`: `tenant_context_permitted = true`
- Browser smoke completed:
1. `/admin/settings/workspace`: saved `Workspace AI policy = Private only` and confirmed the effective summary updated on the real page.
2. `/system/ops/controls`: paused and resumed `AI execution` through the confirmation flow and confirmed both state changes plus success notifications.
- Environment note: the integrated browser's `localhost` system-panel session became stale during smoke work, so the system-panel step completed on `127.0.0.1` with a fresh host-scoped session. Route health and product behavior were otherwise unchanged.
- Deferred to follow-up specs only:
- external-public or broader provider execution
- result persistence, caching, or prompt/output history
- budgeting, credits, or cost controls
- queued AI work or `OperationRun` semantics
- customer-facing AI surfaces or approval workflows
## Notes
- Filament v5 already runs on Livewire v4 in this repo.
- Panel providers remain registered through `bootstrap/providers.php`; this slice does not add or move providers.
- No new globally searchable AI resource is part of v1, so global search behavior stays unchanged.
- `Pause AI execution` and `Resume AI execution` are the only destructive-like actions in scope and must stay confirmation-protected.
- No new registered assets are expected. If implementation later registers a Filament asset anyway, deployment still needs the normal `cd apps/platform && php artisan filament:assets` step.

View File

@ -1,142 +0,0 @@
# Research — Private AI Execution & Policy Foundation
**Date**: 2026-04-27
**Spec**: [spec.md](spec.md)
This document resolves planning unknowns and records the repo-backed decisions that keep Spec 248 narrow.
## Decision 1 — Reuse workspace settings for AI policy truth
**Decision**: Store workspace AI posture as a workspace setting at `ai.policy_mode` on the existing [WorkspaceSettings](../../apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php) page, with validation registered through [SettingsRegistry](../../apps/platform/app/Support/Settings/SettingsRegistry.php) and persistence/audit handled by [SettingsWriter](../../apps/platform/app/Services/Settings/SettingsWriter.php).
**Rationale**:
- The repo already has a singleton workspace settings surface, a central settings registry, and an audited writer path.
- Reusing that stack preserves workspace ownership and avoids inventing a second admin surface or a new AI persistence table.
- The existing workspace settings capabilities already separate view and manage permissions.
**Evidence**:
- [WorkspaceSettings](../../apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php) already owns the `/admin/settings/workspace` singleton route and uses `Capabilities::WORKSPACE_SETTINGS_VIEW` / `Capabilities::WORKSPACE_SETTINGS_MANAGE`.
- [SettingsRegistry](../../apps/platform/app/Support/Settings/SettingsRegistry.php) is the canonical place for setting definitions and validation.
- [SettingsWriter](../../apps/platform/app/Services/Settings/SettingsWriter.php) already persists workspace settings and records `workspace_setting.updated` / `workspace_setting.reset` audit events.
**Alternatives considered**:
- Add a dedicated `workspace_ai_policies` table.
- Rejected: new persisted truth is unnecessary for a single workspace-owned mode and would violate the narrow v1 scope.
- Hide AI posture in environment config or feature flags.
- Rejected: not workspace-owned, not operator-auditable, and not compatible with the product requirement for explicit workspace policy.
## Decision 2 — Reuse the existing operational-controls path for the runtime stop
**Decision**: Add `ai.execution` to [OperationalControlCatalog](../../apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php), evaluate it through [OperationalControlEvaluator](../../apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php), and expose it only on the existing [Controls](../../apps/platform/app/Filament/System/Pages/Ops/Controls.php) page under the current `/system` panel.
**Rationale**:
- The repo already has a platform-only control-center pattern with confirmation, scope previews, and audit logging.
- Reusing it avoids a second AI-specific emergency-stop mechanism or a new system AI console.
- The platform plane auth guard and capability checks are already in place for this page.
**Evidence**:
- [Controls](../../apps/platform/app/Filament/System/Pages/Ops/Controls.php) already owns confirmation-protected pause/resume actions and history for operational controls.
- [OperationalControlCatalog](../../apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php) is the existing source of control keys, labels, and supported scopes.
- [OperationalControlEvaluator](../../apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php) is the existing runtime lookup path.
- [SystemPanelProvider](../../apps/platform/app/Providers/Filament/SystemPanelProvider.php) and [PlatformCapabilities](../../apps/platform/app/Support/Auth/PlatformCapabilities.php) already enforce the `/system` plane and `platform.ops.controls.manage` capability.
**Alternatives considered**:
- Add an AI-specific console or admin page under `/system`.
- Rejected: duplicates the existing ops-controls pattern and broadens v1 without adding new product truth.
- Use a deploy-time environment flag as the emergency stop.
- Rejected: not operator-owned, not auditable, and not aligned with the current control-center workflow.
## Decision 3 — Treat v1 as a governed decision boundary, not an AI provider runtime
**Decision**: The new AI seam should be an in-process governed decision boundary that accepts a registered use-case request and returns an allow/block decision plus audit-ready metadata. It must not include provider adapters, outbound model execution, queue orchestration, or result persistence in this slice.
**Rationale**:
- The spec explicitly avoids direct external provider calls with tenant data, `OperationRun` semantics, result persistence, and a broad marketplace.
- The repo has no existing AI execution layer, so the smallest safe first step is the allow/block contract itself.
- A decision-first seam is enough to stop local provider calls from appearing feature by feature.
**Evidence**:
- There is no app-level AI support namespace in `apps/platform/app/**` today.
- Existing shared seams cover settings, ops controls, audit, product knowledge, and support diagnostics, but none of them own AI allow/block semantics.
**Alternatives considered**:
- Add feature-local AI helpers in product knowledge and diagnostics first.
- Rejected: duplicates policy, provider-class, and data-classification rules across surfaces.
- Build a full provider abstraction layer now.
- Rejected: speculative architecture before the first concrete provider runtime is even in scope.
## Decision 4 — Lock v1 to two approved internal-only use cases and derive them from existing seams
**Decision**: Keep the v1 catalog locked to exactly two use cases:
- `product_knowledge.answer_draft`, anchored to [ContextualHelpResolver](../../apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php) and its code-owned knowledge source
- `support_diagnostics.summary_draft`, anchored to [SupportDiagnosticBundleBuilder](../../apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php) as a derived summary path
**Rationale**:
- These are the two named likely adopters from the spec and both already exist as internal-only seams.
- Limiting the catalog to two concrete consumers satisfies ABSTR-001 while still proving the shared decision vocabulary is reusable.
- Open-ended catalog growth would silently widen scope into a general AI platform.
**Evidence**:
- [ContextualHelpResolver](../../apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php) already exposes `knowledgeSource()` for code-owned product knowledge.
- [SupportDiagnosticBundleBuilder](../../apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php) already produces the diagnostics data family used from the tenant dashboard and the tenantless operation viewer.
**Alternatives considered**:
- Allow any caller to register arbitrary AI use cases at runtime.
- Rejected: creates speculative platform scope and weakens governance.
- Ship only one adopter in v1.
- Rejected: the safety justification for the central catalog is stronger with the two real future consumers already identified by the spec.
## Decision 5 — Support diagnostics input must be a derived redacted summary, not the raw bundle
**Decision**: `support_diagnostics.summary_draft` should consume a derived redacted summary of the support-diagnostics bundle, not the raw `sections` array or the raw provider/context payloads already present in the bundle structure.
**Rationale**:
- The current support-diagnostics bundle is broad, structured, and designed for operator inspection, not AI transport.
- Passing the raw bundle would violate the explicit v1 ban on raw provider payloads, customer-confidential data, and raw evidence excerpts.
- A derived summary keeps the AI boundary honest: if the summary cannot be produced safely, the use case should stay blocked.
**Evidence**:
- [SupportDiagnosticBundleBuilder](../../apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php) currently produces a rich `sections` structure plus contextual help and redaction notes, not a purpose-built AI summary.
**Alternatives considered**:
- Feed the full support-diagnostics bundle into AI with field-level filtering.
- Rejected: still too broad for v1, easier to get wrong, and unnecessary for the first governed foundation slice.
## Decision 6 — Reuse the existing audit pipeline and keep the AI audit family minimal
**Decision**: Reuse [WorkspaceAuditLogger](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and the underlying [AuditActionId](../../apps/platform/app/Support/Audit/AuditActionId.php) / `AuditRecorder` path. Keep workspace policy mutations on the existing `workspace_setting.updated` / `workspace_setting.reset` actions and add one bounded AI decision action ID for governed decision evaluations with structured metadata only.
**Rationale**:
- Policy changes already flow through the workspace settings audit path and should not create a second mutation pattern.
- AI decision evaluations need a stable audit record, but the narrowest shape is one action ID plus metadata, not a full AI run ledger.
- The spec explicitly bans raw prompt, raw source payload, and output persistence.
**Evidence**:
- [SettingsWriter](../../apps/platform/app/Services/Settings/SettingsWriter.php) already logs workspace-setting updates and resets.
- [WorkspaceAuditLogger](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) already records workspace-scoped and tenant-scoped audit entries.
- [AuditActionId](../../apps/platform/app/Support/Audit/AuditActionId.php) is the canonical action registry.
**Alternatives considered**:
- Add a dedicated AI audit table or prompt history store.
- Rejected: violates the v1 no-new-persistence constraint and imports a second source of truth.
- Split AI decisions into many action IDs (`allowed`, `blocked`, `control_blocked`, etc.).
- Rejected for v1: one bounded decision action plus metadata is the smaller audit family.
## Decision 7 — Keep proof narrow: unit + feature + architecture guard
**Decision**: Prove the slice with narrow unit tests for the decision matrix, focused feature tests for the two existing operator surfaces, and one architecture guard that fails if direct AI-provider access appears outside the governed boundary.
**Rationale**:
- Unit coverage is the cheapest place to prove the allow/block matrix.
- Feature coverage is still needed because the slice touches the existing workspace settings and system controls surfaces.
- Browser and heavy-governance workflows would add cost without proving additional v1 truth.
**Evidence**:
- Existing settings and operational-controls tests already show the repo prefers focused Pest feature tests plus targeted unit tests over browser coverage for this class of work.
**Alternatives considered**:
- Add browser smoke coverage in v1.
- Rejected: unnecessary for the narrow foundation slice and not the cheapest proof.
- Reuse the broad `WorkspaceSettingsManageTest.php` family as the primary proof.
- Rejected: it is workflow-heavy and should not become the default proving lane for a narrow AI policy field.

View File

@ -1,348 +0,0 @@
# Feature Specification: Private AI Execution & Policy Foundation
**Feature Branch**: `248-private-ai-policy-foundation`
**Created**: 2026-04-27
**Status**: Implemented
**Input**: User description: "Promote the roadmap-fit candidate Private AI Execution & Policy Foundation as a narrow, implementation-ready slice that introduces a governed central AI execution boundary for approved use cases, workspace policy modes, provider-class gating, and audit-ready decision metadata, while stopping before customer-facing AI features, direct external provider calls with tenant data, or a broad multi-provider marketplace."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot now has roadmap pressure to add AI-assisted support and operator workflows, but the repo still has no app-level AI execution seam, no workspace-owned AI policy truth, and no central place to classify which AI inputs are ever allowed to leave a bounded trust boundary.
- **Today's failure**: If AI work starts feature-by-feature, it will likely appear as local provider calls, local prompt assembly, and local allow/block logic that bypass workspace policy, provider trust boundaries, operational controls, and audit-ready decision metadata. That would create privacy drift, provider coupling, and rework before the first real customer-facing AI workflow even lands.
- **User-visible improvement**: Workspace operators can set an explicit workspace AI posture on the existing workspace settings surface, platform operators can pause all AI execution through the existing operational-controls path, and future AI-assisted internal workflows get one auditable allow-or-block decision before any model execution begins.
- **Smallest enterprise-capable version**: Add one concrete governed AI execution boundary, one code-owned approved use-case catalog locked to two internal-only future consumers (`product_knowledge.answer_draft` and `support_diagnostics.summary_draft`), one workspace AI policy section with the modes `disabled` and `private_only`, one bounded provider-class and data-classification contract, one reused operational-control key for emergency stop, and one audit metadata shape on the existing audit infrastructure.
- **Explicit non-goals**: No customer-facing AI surface, no chatbot, no customer communication drafting, no autonomous remediation, no human-approval workflow, no broad provider marketplace, no provider credential-management UI, no usage budgeting, no result cache/store, no prompt/template CMS, no queueing/OperationRun layer for AI, and no external public-provider execution with tenant or customer data.
- **Permanent complexity imported**: One workspace-owned AI policy truth inside the existing settings stack, one bounded AI use-case catalog, one bounded provider-class catalog, one bounded AI data-classification family, one concrete execution-decision service, one operational-control catalog entry, new audit action IDs and metadata fields, and focused unit plus feature guard coverage.
- **Why now**: This is the next roadmap-fit foundation after Specs 242-247 and the provider-vocabulary hardening lane. It directly reduces the current risk that private AI arrives through ungoverned local feature calls before the product has safe workspace isolation, provider gating, and audit semantics.
- **Why not local**: A local AI helper per surface would duplicate policy checks, duplicate data-classification choices, and teach parallel provider semantics across product knowledge, diagnostics, and later customer workflows. The trust boundary needs to exist once before those consumers start shipping.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New axes, new meta-infrastructure, and foundation-sounding scope. Defense: the slice is tightly limited to two approved use cases, two policy modes, one existing admin settings surface, one existing system control surface, no new table, no result persistence, and no customer-visible AI workflow.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, platform
- **Primary Routes**:
- `/admin/settings/workspace` on the existing workspace settings page for workspace-owned AI policy
- `/system/ops/controls` on the existing system operational-controls page for a platform emergency stop of AI execution
- No new tenant/admin AI output route, customer-facing AI page, or system AI console is introduced in v1
- **Data Ownership**:
- Workspace AI policy truth is workspace-owned and stored through the existing workspace settings mechanism rather than a new AI table
- Approved AI use cases, provider classes, and AI data classifications remain code-owned repository truth
- AI execution decisions and policy mutations are recorded on the existing audit infrastructure; no AI result ledger, cache store, or prompt history table is introduced in this slice
- Tenant-scoped AI requests may carry workspace and tenant identifiers for authorization and audit context, but tenant/customer content remains derived input only and is not persisted as a new AI-owned record family
- **RBAC**:
- Workspace AI policy visibility and mutation stay on the existing workspace settings authorization path and reuse the current workspace settings capabilities
- Platform pause/resume of AI execution stays on the existing system panel and requires `PlatformCapabilities::ACCESS_SYSTEM_PANEL` plus `PlatformCapabilities::OPS_CONTROLS_MANAGE`
- The governed AI execution boundary accepts requests only after the caller has already resolved workspace and optional tenant entitlement on the host surface; it does not create a new cross-plane shortcut from `/system` into tenant data
- This slice introduces no new customer-facing or operator-facing `run AI` capability string because it intentionally stops before any new AI action surface is exposed
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a canonical cross-tenant AI list or detail route
- **Explicit entitlement checks preventing cross-tenant leakage**: AI decision evaluation never runs before the host surface has already resolved workspace and tenant entitlement. A non-member or wrong-scope actor receives the existing 404 semantics before any AI policy or data-classification detail is revealed.
## 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)**: workspace settings, operational safety controls, audit logging, future support-diagnostic and product-knowledge source reuse
- **Systems touched**: existing workspace settings persistence and audit flow, `App\Support\OperationalControls\OperationalControlEvaluator`, `App\Filament\System\Pages\Ops\Controls`, `App\Support\ProductKnowledge\ContextualHelpResolver`, existing support-diagnostic bundle builders, and `App\Support\Audit\AuditActionId`
- **Existing pattern(s) to extend**: workspace settings update/reset audit path, operational-controls evaluation path, platform system-panel capability enforcement, and stable audit action ID conventions
- **Shared contract / presenter / builder / renderer to reuse**: `SettingsResolver`, `SettingsWriter`, `WorkspaceAuditLogger`, `AuditRecorder`, `OperationalControlEvaluator`, `AuditActionId`, `ContextualHelpResolver`, and the existing support-diagnostic summary pipeline
- **Why the existing shared path is sufficient or insufficient**: the existing settings, ops-controls, and audit paths are already sufficient for policy storage, emergency stop, and audit ownership. They are insufficient for AI itself because no central execution boundary or AI-specific allow/block decision contract exists yet.
- **Allowed deviation and why**: none. The first slice must not introduce page-local AI policy checks, page-local provider labels, or page-local audit payloads.
- **Consistency impact**: the same vocabulary for `AI policy mode`, `provider class`, `data classification`, `approved use case`, `blocked reason`, and `private-only` must appear consistently across workspace settings, system controls, audit prose, and all future AI decision callers.
- **Review focus**: reviewers must block any direct provider call, raw feature-level AI helper, or local data-classification rule that bypasses the central AI execution boundary, the workspace AI policy, or the reused operational-control decision.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: N/A - this slice intentionally stops before queueing, background AI work, or customer/operator-facing AI runs
- **Delegated start/completion UX behaviors**: N/A
- **Local surface-owned behavior that remains**: N/A
- **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**: platform-core
- **Seams affected**: AI use-case keys, workspace AI policy vocabulary, provider-class gating, data-classification gating, and the governed execution decision contract
- **Neutral platform terms preserved or introduced**: `AI use case`, `provider class`, `workspace AI policy`, `data classification`, `execution decision`, `source family`, and `private-only`
- **Provider-specific semantics retained and why**: none in v1. The slice intentionally classifies trust boundaries by provider class rather than naming vendors, endpoints, SDKs, or model marketplaces.
- **Why this does not deepen provider coupling accidentally**: the spec keeps provider truth at the class level (`local_private` versus `external_public`) and forbids feature code from depending on vendor-specific semantics or credentials in this foundation slice.
- **Follow-up path**: later provider expansion belongs in follow-up specs, primarily `AI Usage Budgeting, Context & Result Governance` and then `AI-Assisted Customer Operations`, rather than inside this foundation slice
## 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 |
|---|---|---|---|---|---|---|
| Workspace settings AI policy section | yes | Native Filament + existing singleton settings page | settings, status messaging, helper text | page, settings section, resolved policy summary | no | Extends the existing workspace settings page instead of creating a separate AI admin surface |
| System ops controls AI execution control card | yes | Native Filament + existing operational-controls page | operational safety controls, audit-backed state messaging | page, card/action state, confirmation modal | no | Reuses the current control-center pattern for a single new AI execution kill switch |
| Customer-facing or tenant-facing AI output surfaces | no | N/A | none | none | no | `N/A - explicitly out of scope for v1` |
## 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 |
|---|---|---|---|---|---|---|---|
| Workspace settings AI policy section | Primary Decision Surface | Workspace owner or manager decides whether the workspace allows no AI use at all or only private-only AI for approved internal use cases | current policy mode, plain-language effect, approved use cases, allowed provider classes, and blocked data classes | audit attribution, source-family notes, and future-consumer explanation | Primary because this is the one workspace-owned product decision that changes later AI allow/block behavior | Follows configuration-first governance instead of hidden feature flags | Replaces founder memory or code comments with one explicit workspace truth |
| System ops controls AI execution control card | Primary Decision Surface | Platform operator decides whether all new AI execution must be paused during an incident or rollout concern | global control state, reason, expiry, and effect on new AI starts | audit history and affected-use-case summary | Primary because it is the runtime safety stop for the whole AI boundary, not a secondary diagnostic | Follows incident and rollout operations workflow | Removes the need for deploy-time or environment-level emergency stop behavior |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Workspace settings AI policy section | operator-MSP | policy mode, approved use cases, allowed provider classes, blocked data classes, and plain-language effect | last changed attribution and policy-source notes | none | `Save` | vendor-specific credentials, raw prompt examples, raw diagnostic inputs, and future budgeting fields stay out of scope | The same policy vocabulary is reused by the execution boundary and audit prose instead of being restated differently on future surfaces |
| System ops controls AI execution control card | support-platform, operator-platform | control state, reason, expiry, and whether new AI execution is paused | audit history and affected-use-case count | none | `Pause AI execution` or `Resume AI execution` | no prompt content, no provider payload preview, and no workspace content samples appear on the control surface | The control surface owns only runtime stop/start truth; workspace policy detail stays on workspace settings |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings AI policy section | Config / Settings / Singleton | Workspace configuration section | Save or reset the workspace AI policy | In-page settings section on the existing singleton route | forbidden | Helper text and policy explanation stay inside the section | none | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Workspace AI policy | Whether AI is disabled or private-only, and what that means | existing singleton-settings exception remains valid |
| System ops controls AI execution control card | Utility / System | Operational safety control center | Pause or resume AI execution | Same-page card actions and confirmation modal | forbidden | Audit/history detail remains secondary inside the page | pause/resume stays on the card with confirmation | `/system/ops/controls` | `/system/ops/controls` | Platform-global control scope | AI execution control | Whether new AI execution is allowed right now and why | 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings AI policy section | Workspace owner or manager | Decide whether the workspace allows private-only AI for approved internal use cases | Singleton settings page | What AI posture applies to this workspace right now? | policy mode, approved use cases, allowed provider classes, blocked data classes, and plain-language effect | last changed attribution and source-family notes | AI policy mode, provider trust boundary, allowed data scope | TenantPilot only | Save, Reset policy | none |
| System ops controls AI execution control card | Platform operator | Decide whether all new AI execution must be paused or resumed | System control center | Should any new AI execution proceed right now? | global control state, reason, expiry, and effect on new starts | audit history and affected use-case summary | global runtime safety state | TenantPilot only | Pause AI execution, Resume AI execution | Pause AI execution, Resume AI execution |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - workspace-owned AI policy becomes current-release product truth
- **New persisted entity/table/artifact?**: no - workspace AI policy reuses existing workspace settings persistence and audit paths
- **New abstraction?**: yes - one concrete governed AI execution boundary and one bounded use-case catalog
- **New enum/state/reason family?**: yes - AI policy modes, provider classes, data classifications, and execution decision reasons
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: TenantPilot needs a safe way to add AI later without letting support, diagnostics, or customer workflows bypass workspace isolation, private-only trust posture, and auditability.
- **Existing structure is insufficient because**: there is currently no app-level AI seam at all. Existing settings, ops controls, and audit paths can store policy and stop work, but they cannot classify AI input, bind use cases to approved data, or force all future AI callers through one decision.
- **Narrowest correct implementation**: keep persistence inside existing workspace settings, reuse existing system ops controls for the emergency stop, lock the use-case catalog to two internal-only future consumers, classify only the first-slice provider/data families, and write audit metadata to the existing audit log instead of building a second AI record system.
- **Ownership cost**: ongoing review of use-case keys, provider-class vocabulary, data classifications, audit metadata shape, and one architecture guard against direct provider calls
- **Alternative intentionally rejected**: direct feature-level AI helpers were rejected as unsafe; a broad provider registry or marketplace was rejected as speculative; a result ledger, cache, or budgeting system was rejected because the first slice does not yet need those truths.
- **Release truth**: current-release truth that deliberately prepares later AI features without shipping them yet
### 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 the approved use-case catalog, workspace AI policy resolution, provider-class and data-classification gating, operational-control precedence, and audit-metadata shaping. Focused feature coverage proves the existing workspace settings and system controls surfaces, plus one architecture guard that blocked requests never reach a direct provider call path.
- **New or expanded test families**: focused AI policy and execution-decision unit coverage, workspace settings feature coverage, operational-control integration feature coverage, and one architecture guard that blocks direct AI provider calls outside the governed boundary
- **Fixture / helper cost impact**: low-to-moderate. Reuse existing workspace, membership, settings, platform-user, and system control fixtures. Avoid browser harnesses, provider-emulator suites, or any seeded AI result history.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament
- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for workspace settings and system ops controls. The central AI execution boundary also needs direct service-level tests proving that blocked requests produce no provider call and no raw audit payload.
- **Reviewer handoff**: reviewers must confirm that `ai.execution` uses the existing operational-control path, workspace policy changes reuse the existing settings audit path, unregistered use cases or blocked data classes never reach provider resolution, and no result store, queue, or customer-facing AI surface slipped into the slice.
- **Budget / baseline / trend impact**: low increase in narrow unit and feature coverage only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=WorkspaceAiPolicy`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=GovernedAiExecution`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=AiExecutionArchitectureGuard`
## First-Slice Approved AI Use Case Inventory *(implementation lock-in for v1)*
The first slice is locked to the following approved use cases. Adding a third use case requires an explicit spec update.
| Use Case Key | Intended Future Consumer | Allowed Provider Class(es) | Allowed Data Classification(s) | Visibility | Tenant Context Permitted | Explicitly Excluded Inputs |
|---|---|---|---|---|---|---|
| `product_knowledge.answer_draft` | Product knowledge and contextual help from `ContextualHelpResolver` and related code-owned knowledge sources | `local_private` | `product_knowledge`, `operational_metadata` | internal-only draft | no | tenant policy JSON, raw provider payloads, customer-confidential notes, personal data |
| `support_diagnostics.summary_draft` | Support diagnostics using a redacted summary derived from existing support-diagnostic bundle builders | `local_private` | `redacted_support_summary` | internal-only draft | yes | raw diagnostic bundle sections, raw provider payloads, customer-confidential notes, personal data |
## First-Slice AI Data Classification Contract *(implementation lock-in for v1)*
| Data Classification | Meaning In This Slice | V1 Consequence |
|---|---|---|
| `product_knowledge` | Code-owned glossary, contextual-help, and product documentation source content with no tenant/customer payload | Allowed only for approved use cases on `local_private` |
| `operational_metadata` | Minimal non-secret metadata such as safe surface family, route family, or internal workflow context that does not contain tenant/customer content | Allowed only when the approved use case explicitly opts in |
| `redacted_support_summary` | Sanitized support-diagnostic summary content derived from existing product truth without raw provider payloads or customer-confidential detail | Allowed only for `support_diagnostics.summary_draft` on `local_private` |
| `personal_data` | End-user or operator personal data | Blocked for all AI execution in v1 |
| `customer_confidential` | Tenant/customer-confidential narrative, sensitive configuration detail, or customer-owned context that is not reduced to the approved redacted summary | Blocked for all AI execution in v1 |
| `raw_provider_payload` | Raw provider payloads, raw policy JSON, raw Graph/API responses, or equivalent source material | Blocked for all AI execution in v1 |
## Scope Boundaries *(required for this slice)*
### In Scope
- One concrete governed AI execution boundary that all future AI callers must use
- One code-owned approved-use-case catalog locked to `product_knowledge.answer_draft` and `support_diagnostics.summary_draft`
- One workspace-owned AI policy section on the existing workspace settings page with the modes `disabled` and `private_only`
- One bounded provider-class contract with `local_private` and `external_public`, where `external_public` exists only as a blocked trust class in v1
- One bounded AI data-classification contract as defined above
- One reused operational-control key `ai.execution` on the existing system ops controls surface
- AI decision audit metadata written to the existing audit infrastructure with no prompt/output persistence
- Architecture guardrails that prevent direct provider calls outside the governed boundary
### Non-Goals
- Customer-facing AI features, tenant-facing AI summaries, or support-response drafting surfaces
- Broad provider marketplace, vendor credential management, or multi-provider routing UI
- Token or cost budgeting, credits, rate limits, or queue priority rules
- Result cache, prompt store, output history, or reusable AI artifact persistence
- Autonomous remediation, legal/customer communications, or human-approval workflow for AI outputs
- External public-provider execution with tenant/customer data
- Queueing, retries, or `OperationRun` semantics for AI execution in this slice
## Assumptions
- The existing workspace settings persistence and audit path are sufficient for storing one workspace AI policy mode without introducing a new table.
- The operational-controls foundation from the existing controls page can safely absorb one additional control key for AI execution.
- `ContextualHelpResolver` and support-diagnostic builders can provide code-owned or redacted source inputs without requiring raw provider payloads to cross the AI boundary.
- The first slice remains internal-only and draft-only, so no customer-visible AI wording, approval queue, or outbound communication contract is needed yet.
## Risks
- If the support-diagnostic pipeline cannot produce a clearly redacted summary without raw provider payloads or customer-confidential detail, `support_diagnostics.summary_draft` may need a tighter pre-step before implementation proceeds.
- If the operational-controls slice is unavailable or materially different at implementation time, the `ai.execution` emergency stop may need sequencing adjustment before this feature can land safely.
- A later implementer could still try to add a vendor-specific provider seam or prompt history while wiring the first private model. The architecture guard must stay explicit so the slice does not widen silently.
- A workspace policy surface without an enforced central execution boundary would create false confidence. The execution guard and architecture guard are both mandatory for safe implementation.
## Follow-up Candidates
- AI Usage Budgeting, Context & Result Governance
- AI-Assisted Customer Operations
- Decision-pack or review-workspace AI draft assistance after explicit human-approval and evidence-governance rules exist
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Set workspace AI posture once (Priority: P1)
As a workspace owner or manager, I want to choose whether the workspace disables AI entirely or allows only private-only AI for approved internal use cases so the product has one explicit trust posture before any AI feature is added.
**Why this priority**: The foundation is not safe unless workspace-owned AI posture is explicit, auditable, and visible before later AI use cases appear.
**Independent Test**: Open the existing workspace settings page, change the AI policy between `disabled` and `private_only`, and verify that the resolved policy explanation updates and is attributable without touching application code or environment flags.
**Acceptance Scenarios**:
1. **Given** a workspace manager opens workspace settings, **When** they save the AI policy mode as `private_only`, **Then** the page shows that only approved private-only use cases may proceed and the change is attributable through the existing workspace settings audit path.
2. **Given** the same workspace changes the mode back to `disabled`, **When** the page reloads, **Then** the page shows that no AI execution is allowed for the workspace and future approved use cases would block before execution.
---
### User Story 2 - Block unsafe AI requests before any provider call (Priority: P1)
As the product owner responsible for later AI-assisted operator workflows, I want every in-scope AI request to pass through one governed allow-or-block decision so unapproved use cases, external-public trust classes, or disallowed data classes never reach a provider call.
**Why this priority**: This is the core safety outcome of the foundation. If requests can still bypass the boundary, the slice fails even if the settings UI exists.
**Independent Test**: Exercise the governed AI boundary with the two approved use cases and several blocked combinations, and verify that allowed requests only accept the approved private input shape while blocked requests never resolve a provider call.
**Acceptance Scenarios**:
1. **Given** a workspace is set to `private_only` and a request uses `support_diagnostics.summary_draft` with `redacted_support_summary`, **When** the governed AI boundary evaluates the request for `local_private`, **Then** it allows the request and records an audit-ready decision without persisting prompt or output text.
2. **Given** the same workspace and use case, **When** a request declares `external_public` as the provider class, **Then** the boundary blocks the request before any provider resolution or outbound call occurs.
3. **Given** any workspace AI mode other than `disabled`, **When** a request includes `raw_provider_payload`, `customer_confidential`, or `personal_data`, **Then** the boundary blocks the request before execution even if the requested provider class is `local_private`.
4. **Given** a request uses an unregistered AI use case key or lacks workspace context, **When** the boundary evaluates it, **Then** the request is rejected and no AI provider call is attempted.
---
### User Story 3 - Pause all AI execution centrally during an incident (Priority: P2)
As a platform operator, I want to pause all new AI execution from the existing system ops controls surface so rollout problems or privacy concerns can be contained without a deployment.
**Why this priority**: Reusing the operational-controls pattern is the smallest safe incident stop for a cross-cutting AI boundary.
**Independent Test**: Pause `ai.execution` from the existing controls page, send an otherwise valid AI request through the governed boundary, and verify that it blocks with the operational-control reason until the control is resumed.
**Acceptance Scenarios**:
1. **Given** `ai.execution` is paused from `/system/ops/controls`, **When** an otherwise valid approved AI request is evaluated, **Then** the request is blocked before execution and the block reason identifies the active operational control.
2. **Given** the same control is resumed, **When** the same approved request is retried, **Then** the request follows normal workspace policy and data-classification evaluation again.
### Edge Cases
- A request may arrive without workspace context or with tenant context from an unauthorized actor; the host authorization boundary must fail first so the AI layer does not leak tenant or policy detail.
- A support-diagnostic request may contain mixed safe and unsafe source material; if the source cannot be reduced to `redacted_support_summary`, the entire AI request is blocked.
- A workspace may be set to `private_only` while the platform-level `ai.execution` control is paused; the pause control wins and blocks all new starts.
- An AI request may be accepted just before `ai.execution` is paused; the control governs new starts only and does not retroactively mutate any in-flight private execution.
- A later feature may try to introduce a third use case or a new data classification in the same implementation PR; that is out of scope unless the active spec is updated explicitly.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no Microsoft Graph contract change, no tenant-changing provider write, and no new queued workflow family. It creates a governed decision boundary that must run before any future AI provider execution, while reusing the existing workspace settings, operational-controls, and audit infrastructure.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces new AI-specific vocabulary and one new execution boundary because the current-release product now needs a safe first truth for AI policy, provider trust class, and allowed data before broader AI features land. It stays narrow by avoiding new tables, queues, result persistence, or provider-marketplace abstractions.
**Constitution alignment (XCUT-001):** This slice is cross-cutting across workspace settings, operational controls, audit logging, product-knowledge input, and support-diagnostic input. It must reuse the existing settings and ops-controls paths rather than creating page-local AI settings or emergency-stop logic.
**Constitution alignment (PROV-001):** AI provider trust is classified through neutral provider classes, not vendor-specific names. Provider-specific semantics and provider credential management remain out of scope.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature lanes. The feature must add one explicit architecture guard proving that AI provider access cannot be called directly outside the governed boundary.
**Constitution alignment (OPS-UX):** This slice does not create or reuse an `OperationRun`. If a later AI feature becomes queued or operationally relevant, that behavior belongs in a follow-up spec and must adopt the canonical Ops-UX contract then.
**Constitution alignment (RBAC-UX):** The slice spans workspace `/admin` settings and platform `/system` operational controls. Wrong-plane or non-member access remains 404. Existing workspace settings authorization stays authoritative for policy mutation. Existing system-panel capability enforcement stays authoritative for the emergency stop. The governed AI boundary must not become an authorization bypass for tenant-scoped content.
**Constitution alignment (BADGE-001):** If policy mode or control state is shown with a badge or status chip, the rendering must reuse existing settings/control status semantics rather than introduce page-local AI color language.
**Constitution alignment (UI-FIL-001):** The only operator-facing surfaces in scope are existing Filament pages. The feature must use native sections, helper text, callouts, actions, and control cards rather than a custom AI admin shell.
**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels must stay implementation-light and product-truthful: `Workspace AI policy`, `Disabled`, `Private only`, `Approved AI use cases`, `Blocked data classes`, and `AI execution`. Terms such as vendor names, SDK names, or low-level model endpoint jargon stay out of primary labels.
**Constitution alignment (DECIDE-001):** Workspace settings and system ops controls are the only decision surfaces in scope. No new decision inbox, AI draft viewer, or evidence-heavy AI result page is introduced.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing singleton settings and control-center page patterns. It may not add redundant inspect actions, shadow routes, or mixed action groups for AI management in this first slice.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Workspace policy mutation stays on the workspace settings page. Platform-wide pause/resume stays on the existing controls page. No other visible AI mutation action is introduced.
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first: whether AI is disabled or private-only for a workspace, and whether all new AI execution is paused globally. No raw prompt content, model internals, or tenant payload excerpts belong on the default surfaces.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One decision layer is justified because direct reads from raw settings or local feature flags would still force each future AI surface to duplicate provider-class, data-classification, and policy logic. Tests must target business outcomes such as allowed versus blocked execution and clean audit payloads instead of cosmetic rendering alone.
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied. Workspace settings keep a single in-page save model. System ops controls keep confirmation-protected state-change actions on the same surface. No redundant inspect action or empty action group is introduced.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The workspace AI policy stays inside the existing settings layout with sectioned content and plain-language guidance. The system AI execution stop stays inside the existing controls page. No new custom layout family is introduced.
### Functional Requirements
- **FR-248-001 Approved use-case catalog**: The system MUST define a code-owned AI use-case catalog locked to exactly two first-slice keys: `product_knowledge.answer_draft` and `support_diagnostics.summary_draft`.
- **FR-248-002 Use-case declaration contract**: Each first-slice use case MUST declare its allowed provider class, allowed data classifications, source family, visibility (`internal-only draft`), and whether tenant context is permitted.
- **FR-248-003 Workspace AI policy truth**: The system MUST store workspace AI posture through the existing workspace settings mechanism and audit policy changes through the existing workspace settings audit path.
- **FR-248-004 First-slice policy modes**: The first slice MUST support exactly two workspace AI policy modes: `disabled` and `private_only`.
- **FR-248-005 Provider-class contract**: The system MUST define a bounded provider-class contract containing `local_private` and `external_public`, where `external_public` exists only as a blocked trust class in v1.
- **FR-248-006 Data-classification contract**: The system MUST classify AI inputs using the first-slice data classifications defined in this spec and MUST block `personal_data`, `customer_confidential`, and `raw_provider_payload` for all AI execution in v1.
- **FR-248-007 Central execution boundary**: The system MUST route every future AI execution request through one governed execution boundary that requires a registered use case key, actor context, workspace context, requested provider class, declared data classification, and source family before execution is attempted.
- **FR-248-008 Block precedence**: After the host surface has already resolved authorization and scope entitlement, the governed boundary MUST evaluate `ai.execution` operational control, workspace AI policy mode, use-case registration, provider-class allowance, and data-classification allowance before resolving any AI provider call.
- **FR-248-009 Operational-control reuse**: The feature MUST reuse the existing operational-controls pattern through a new in-scope control key `ai.execution` on `/system/ops/controls` rather than introducing a second AI-specific emergency stop mechanism.
- **FR-248-010 Approved source inputs only**: `product_knowledge.answer_draft` MUST consume only code-owned product-knowledge sources, and `support_diagnostics.summary_draft` MUST consume only redacted support-diagnostic summary content. Raw provider payloads, raw policy JSON, and customer-confidential notes are out of scope.
- **FR-248-011 Audit metadata shape**: The system MUST write stable AI-related audit entries for workspace policy changes and AI execution decisions, including at minimum use case key, provider class, workspace AI policy mode, data classification, decision outcome, decision reason, workspace scope, tenant scope when present, source family, and an optional context fingerprint; audit entries MUST NOT store raw prompt text, raw source payloads, or full output text.
- **FR-248-012 No direct provider calls**: Feature code MUST NOT call AI providers directly. A guard test or equivalent architecture check MUST fail if AI provider access appears outside the central governed boundary.
- **FR-248-013 Workspace settings UX**: The existing workspace settings page MUST show the selected AI policy mode, plain-language effect, approved use cases, allowed provider classes, and blocked data classes without introducing vendor-specific admin UI.
- **FR-248-014 Pause semantics**: When `ai.execution` is paused, all new AI execution requests MUST block before provider resolution, while in-flight work already accepted before the pause MAY complete unchanged.
- **FR-248-015 No hidden scope growth**: The first slice MUST NOT introduce customer-facing AI output surfaces, external public-provider execution with tenant/customer data, AI result persistence, cost budgeting, queue/retry behavior, or a provider marketplace.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings AI policy section | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` | N/A - singleton settings page | none | none | N/A | N/A | `Save`; optional `Reset policy` if the page already supports per-setting reset interactions | yes | Reuses the existing workspace settings mutation and audit path; no new AI execution action appears here |
| System ops controls AI execution control card | `app/Filament/System/Pages/Ops/Controls.php` | `Pause AI execution`, `Resume AI execution`, `View history` | Same-page control card or confirmation modal | none | none | none | same-page actions only | `Review impact`, `Save changes`, `Cancel` inside the existing control modal flow | yes | Reuses `PlatformCapabilities::OPS_CONTROLS_MANAGE` and the existing operational-controls action pattern; no new system AI console is introduced |
### Key Entities *(include if feature involves data)*
- **Workspace AI Policy**: The workspace-owned policy truth that resolves whether AI is `disabled` or `private_only` for the workspace.
- **Approved AI Use Case Definition**: The code-owned catalog entry that defines one allowed AI purpose, its allowed provider class, allowed data classifications, source family, and visibility.
- **AI Execution Request**: The derived request envelope passed into the governed boundary containing actor, workspace, optional tenant, use case key, provider class, data classification, and source provenance.
- **AI Execution Decision**: The allow-or-block result returned by the governed boundary, including policy mode, matched operational-control state, decision reason, and audit-ready metadata.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-248-001**: In validation scenarios, 100% of in-scope AI requests with an unregistered use case, blocked provider class, blocked data classification, missing workspace context, or active `ai.execution` control are stopped before any provider resolution or outbound call occurs.
- **SC-248-002**: Workspace owners can set and review the workspace AI policy on the existing workspace settings page in under 2 minutes without editing environment variables or code.
- **SC-248-003**: In validation coverage, 0 external-public AI executions occur for tenant/customer data in the first slice.
- **SC-248-004**: The two approved first-slice AI use cases resolve through the same governed decision vocabulary and audit metadata shape, with no direct provider call sites outside the central boundary in guard coverage.

View File

@ -1,194 +0,0 @@
---
description: "Task list for Private AI Execution & Policy Foundation"
---
# Tasks: Private AI Execution & Policy Foundation
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md`
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in focused `Unit` and `Feature` lanes, plus one architecture guard, using the targeted Sail commands captured in the feature artifacts.
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or result ledger is introduced. This slice remains DB-backed settings, operational-control, and audit work only.
**RBAC**: Existing workspace settings authorization and platform ops-control authorization remain authoritative. Non-members or wrong-plane actors keep `404` deny-as-not-found semantics where applicable; members missing the required capability receive `403`.
**Provider Boundary**: AI trust vocabulary stays platform-core and vendor-neutral (`AI use case`, `provider class`, `data classification`). `external_public` remains blocked in v1.
**Organization**: Tasks are grouped by user story so workspace AI policy, governed decision enforcement, and operational-stop controls remain independently testable once the shared foundation exists.
## Test Governance Checklist
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/Ai/`, `apps/platform/tests/Feature/SettingsFoundation/`, `apps/platform/tests/Feature/OperationalControls/`, `apps/platform/tests/Feature/System/OpsControls/`, and `apps/platform/tests/Feature/Guards/` only; no browser or heavy-governance lane is added.
- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; do not add provider emulators, queue scaffolding, or seeded AI history.
- [x] Planned validation commands cover workspace settings, governed AI decision logic, audit metadata, operational controls, and the no-direct-provider guard without widening scope.
- [x] The declared surface test profile remains `standard-native-filament` because the slice only extends existing workspace settings and system controls pages.
- [x] Any deferred public-provider execution, result persistence, budgeting, or queued AI follow-up resolves as `document-in-feature` or `follow-up-spec`, not as hidden scope growth.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded first slice, repo seams, and reviewer stop conditions before runtime implementation begins.
- [x] T001 Review the bounded slice, explicit non-goals, approved use cases, validation lanes, and guardrail expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md`
- [x] T002 [P] Confirm the existing workspace settings persistence, resolver, and audit seams that this slice must reuse in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`, `apps/platform/app/Support/Settings/SettingsRegistry.php`, `apps/platform/app/Services/Settings/SettingsResolver.php`, `apps/platform/app/Services/Settings/SettingsWriter.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- [x] T003 [P] Confirm the existing operational-control, platform authorization, and guard-test seams that this slice must extend in `apps/platform/app/Filament/System/Pages/Ops/Controls.php`, `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`, `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php`, `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`, `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared AI policy, decision, audit, and operational-stop primitives that every user story depends on.
**Critical**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add the `ai.policy_mode` setting definition, allowed values, system default, and resolver plumbing in `apps/platform/app/Support/Settings/SettingsRegistry.php` and `apps/platform/app/Services/Settings/SettingsResolver.php`
- [x] T005 [P] Create the bounded AI support namespace for policy modes, provider classes, data classifications, and request/decision value objects under `apps/platform/app/Support/Ai/`
- [x] T006 Implement the code-owned approved-use-case catalog locked to `product_knowledge.answer_draft` and `support_diagnostics.summary_draft` in `apps/platform/app/Support/Ai/AiUseCaseCatalog.php` and companion definition files under `apps/platform/app/Support/Ai/`
- [x] T007 Implement the governed AI execution boundary so host-surface authorization stays a caller-side precondition, then evaluate `ai.execution`, workspace policy, use-case registration, provider class, and data-classification allowance in `apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php`
- [x] T008 [P] Add the bounded AI decision audit action and metadata-shaping support without prompt, source-payload, or output persistence in `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and `apps/platform/app/Support/Ai/`
- [x] T009 [P] Add the `ai.execution` operational-control definition and evaluator lookup path in `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php` and `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php`
**Checkpoint**: Shared workspace policy, governed AI decision, audit metadata, and runtime stop primitives exist; user stories can now proceed independently.
---
## Phase 3: User Story 1 - Set Workspace AI Posture Once (Priority: P1) MVP
**Goal**: Let a workspace owner or manager set one explicit workspace AI posture on the existing settings surface before any later AI-assisted workflow is added.
**Independent Test**: Open `/admin/settings/workspace`, save `disabled` and `private_only`, verify the resolved explanation and approved-use-case summary update on the existing settings page, and confirm authorized and unauthorized actors still get the expected settings semantics.
### Tests for User Story 1
- [x] T010 [P] [US1] Add feature coverage for saving, resetting, and rendering the workspace AI policy section on the existing settings page in `apps/platform/tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php`
- [x] T011 [P] [US1] Extend positive and negative workspace-settings authorization coverage so non-members stay `404`, members without manage capability stay `403`, and authorized managers can mutate `ai.policy_mode` in `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`, and `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`
- [x] T012 [P] [US1] Extend workspace-settings audit coverage for AI policy mode updates and resets in `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php`
### Implementation for User Story 1
- [x] T013 [US1] Add the `Workspace AI policy` section, approved use-case summary, allowed provider-class summary, and blocked data-class explanation to `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
- [x] T014 [US1] Persist `ai.policy_mode` through the existing audited settings stack in `apps/platform/app/Support/Settings/SettingsRegistry.php`, `apps/platform/app/Services/Settings/SettingsResolver.php`, and `apps/platform/app/Services/Settings/SettingsWriter.php`
- [x] T015 [US1] Keep page-level save and reset behavior, helper text, and default-visible policy explanation derived from the central AI catalog instead of page-local strings in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Support/Ai/`
**Checkpoint**: User Story 1 is independently functional when the workspace settings page owns one explicit AI posture with correct audit and authorization behavior.
---
## Phase 4: User Story 2 - Block Unsafe AI Requests Before Provider Resolution (Priority: P1)
**Goal**: Force every in-scope AI request through one governed allow-or-block decision so unregistered use cases, blocked trust classes, and blocked data classifications never reach provider resolution.
**Independent Test**: Exercise the governed AI boundary with approved and blocked request combinations, verify allowed private-only requests use only approved source families, and prove blocked requests never resolve a provider call.
### Tests for User Story 2
- [x] T016 [P] [US2] Add unit coverage for the approved-use-case catalog and declared provider-class and data-classification rules in `apps/platform/tests/Unit/Support/Ai/AiUseCaseCatalogTest.php`
- [x] T017 [P] [US2] Add unit coverage for boundary precedence across missing workspace context, unregistered use cases, blocked provider classes, blocked data classifications, `disabled`, `private_only`, and allowed private-only requests in `apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php`
- [x] T018 [P] [US2] Add unit coverage for AI decision audit metadata shape and explicit exclusion of prompt text, raw source payloads, raw provider payloads, and output text in `apps/platform/tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php`
- [x] T019 [P] [US2] Add architecture-guard coverage that no direct AI provider call or vendor-specific runtime entry appears outside the governed boundary in `apps/platform/tests/Feature/Guards/NoDirectAiProviderBypassTest.php`
### Implementation for User Story 2
- [x] T020 [US2] Finalize the governed request and decision contract plus no-provider-resolution behavior inside `apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php` and its request/decision collaborators under `apps/platform/app/Support/Ai/`
- [x] T021 [US2] Expose only approved source-family inputs for `product_knowledge.answer_draft` and `support_diagnostics.summary_draft` from `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` without adding customer-facing AI UI, public-provider execution, or result persistence
- [x] T022 [US2] Route governed AI decision evaluation through the existing audit pipeline with stable allow-or-block metadata and no prompt/output persistence in `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and `apps/platform/app/Support/Ai/`
**Checkpoint**: User Story 2 is independently functional when the central AI boundary blocks unsafe requests before provider resolution and records bounded audit metadata only.
---
## Phase 5: User Story 3 - Pause All AI Execution Centrally During An Incident (Priority: P2)
**Goal**: Let a platform operator pause and resume new AI execution from the existing system operational-controls surface without introducing a second AI admin console.
**Independent Test**: Pause `ai.execution` on `/system/ops/controls`, verify an otherwise valid governed AI request blocks with the operational-control reason, then resume the control and verify normal policy evaluation resumes.
### Tests for User Story 3
- [x] T023 [P] [US3] Add feature coverage for pausing and resuming `ai.execution` on the existing controls page, including confirmation-backed state changes and visible control history, in `apps/platform/tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php` and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
- [x] T024 [P] [US3] Extend positive and negative platform authorization coverage so `platform.access_system_panel` plus `platform.ops.controls.manage` remain authoritative for `ai.execution` pause/resume in `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`
- [x] T025 [P] [US3] Extend governed-boundary coverage so an active `ai.execution` control blocks otherwise valid requests until the control is resumed in `apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php`
### Implementation for User Story 3
- [x] T026 [US3] Add the `ai.execution` control definition, operator-facing label, global-only scope, and evaluator lookup semantics in `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php` and `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php`
- [x] T027 [US3] Add the AI execution control card plus confirmation-protected `Pause AI execution` and `Resume AI execution` actions to the existing system controls surface in `apps/platform/app/Filament/System/Pages/Ops/Controls.php`
- [x] T028 [US3] Keep operational-control copy, blocked-reason vocabulary, and control-history presentation aligned across `apps/platform/app/Filament/System/Pages/Ops/Controls.php` and `apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php` without introducing a new AI capability string or system AI console
**Checkpoint**: User Story 3 is independently functional when the existing system controls page can pause and resume new AI execution and the boundary honors that stop immediately for new requests.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish narrow validation, formatting, and reviewer close-out without widening scope.
- [x] T029 [P] Run the focused unit validation commands recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` for `apps/platform/tests/Unit/Support/Ai/AiUseCaseCatalogTest.php`, `apps/platform/tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php`, and `apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php`
- [x] T030 [P] Run the focused workspace-settings validation commands recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` for `apps/platform/tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`, and `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php`
- [x] T031 [P] Run the focused system-control and architecture-guard validation commands recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` for `apps/platform/tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php`, `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`, `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/Guards/NoDirectAiProviderBypassTest.php`
- [x] T032 Run dirty-only formatting for touched platform files through `apps/platform/vendor/bin/sail` using the Pint command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md`
- [x] T033 Record the TEST-GOV-001 outcome, guardrail close-out, and any `document-in-feature` or `follow-up-spec` deferrals for public-provider execution, result persistence, budgeting, or queued AI work in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the workspace-owned policy truth.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 because policy without a governed boundary would create false confidence.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US2 because the boundary must already honor `ai.execution` for the system control to be meaningful.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2, but not safe to ship alone.
- **US2 (P1)**: independently testable after Phase 2 and must pair with US1 for a safe MVP.
- **US3 (P2)**: independently testable after Phase 2, but depends on the governed boundary from US2 to prove runtime stop behavior.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended behavior gap.
- Complete shared service enforcement before wiring the corresponding Filament surface.
- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story.
---
## Parallel Execution Examples
### User Story 1
- T010, T011, and T012 can run in parallel before runtime edits begin.
- After test scaffolding exists, T013 and T014 can proceed in parallel because the page wiring and settings-stack persistence touch different files; T015 should follow both.
### User Story 2
- T016, T017, T018, and T019 can run in parallel because they cover separate unit and guard files.
- After T020 settles the governed contract, T021 and T022 can proceed in parallel because source-family helpers and audit plumbing live on separate seams.
### User Story 3
- T023, T024, and T025 can run in parallel before implementation starts.
- T026 should land before T027, and T028 should follow both so control-surface wording and boundary reason vocabulary stay consistent.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. Workspace policy alone is not safe to ship because the spec explicitly requires the governed boundary that enforces the policy before any provider resolution can occur.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and US2 together, then validate the settings-backed policy plus governed boundary behavior.
3. Deliver US3 to add the runtime stop on the existing system controls surface.
4. Finish with narrow validation, formatting, and feature-level close-out in Phase 6.
### Team Strategy
1. Finish Phase 2 together before splitting story work.
2. Parallelize test authoring inside each story first.
3. Serialize merges around `apps/platform/app/Support/Ai/` and `apps/platform/app/Filament/System/Pages/Ops/Controls.php`, because those seams are shared by multiple story tasks.

View File

@ -1,72 +0,0 @@
# Preparation Review Checklist: Customer Review Workspace v1
**Purpose**: Validate the customer-safe review-consumption package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Applicability And Low-Impact Gate
- [x] CHK001 The package explicitly treats this as an operator-facing and read-only/customer-safe surface change, so the low-impact `N/A` path is not used.
- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording.
## Native, Shared-Family, And State Ownership
- [x] CHK003 The primary surface remains a native Filament page that composes existing review, pack, and evidence viewers instead of introducing a fake-native shell or standalone customer portal.
- [x] CHK004 Shared detail families remain shared: tenant review, review pack, and evidence detail stay on their existing resource routes, while the new page stays a calm entry point rather than a parallel viewer family.
- [x] CHK005 Shell, page, and URL/query state owners are named once, and the package does not collapse them into new persisted customer-review state.
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: `Open latest review` is primary, `Download review pack` is the only safe inline shortcut, and deeper proof stays secondary.
## Shared Pattern Reuse
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `TenantReviewRegisterService`, existing resource URL helpers, `ArtifactTruthPresenter`, `ReviewPackService`, `RedactionIntegrity`, and the audit pipeline.
- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded page-local read helper or additive audit action is explicitly constrained as a last resort rather than a new default abstraction.
- [x] CHK009 The package does not create a parallel customer-review UX language; it reuses current artifact-truth, publication-readiness, review-pack, and redaction vocabulary.
## OperationRun Start UX Contract
- [x] CHK019 The package explicitly states that the new page does not create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` as a primary workflow.
- [x] CHK020 Any existing `OperationRun` links remain on reused detail surfaces, so queued toast/link/browser-event/dedupe behavior is not reimplemented on the customer workspace page.
- [x] CHK021 No queued DB notification behavior or terminal notification path is added because the slice stays read-only and never starts a run.
- [x] CHK022 No OperationRun exception is required; if implementation later promotes run-oriented behavior onto the page, that deviation must be recorded in the active close-out entry before merge.
## Provider Boundary And Vocabulary
- [x] CHK010 The package keeps provider-specific semantics behind existing normalized review, evidence, and artifact-truth seams and does not spread provider language into a new platform-core contract.
- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays within current workspace, tenant, review, evidence, review-pack, accepted-risk, and audit vocabulary.
## Signals, Exceptions, And Test Depth
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package.
- [x] CHK013 No bounded exception is required in the preparation package; if implementation discovers a local read helper or additive audit action is unavoidable, that exception must be documented in the active feature close-out entry instead of becoming silent spread.
- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the page plus `shared-detail-family` for navigation into existing review, pack, and evidence detail surfaces.
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this disclosure-heavy slice: focused Feature coverage plus one bounded Browser smoke, with optional Unit coverage only if a small read helper is extracted.
## Audience-Aware Disclosure And Decision Hierarchy
- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support/raw evidence.
- [x] CHK024 The read-only/customer-safe default path does not expose raw JSON, copied payloads, fingerprints, internal reason ownership, platform-debug semantics, or unrestricted audit detail by default.
- [x] CHK025 Exactly one dominant next action is primary: `Open latest review`; safe artifact download remains secondary and does not compete at equal weight.
- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by reusing one artifact-truth summary per row and leaving detailed proof to the existing detail surfaces.
- [x] CHK027 Support/raw sections remain hidden or capability-gated through reused detail routes only, and the page keeps Filament visual language, progressive disclosure, and calm read-only presentation.
## Review Outcome
- [x] CHK016 Review outcome class: `acceptable-special-case`
- [x] CHK017 Workflow outcome: `keep`
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records the guardrail result, smoke outcome, and any bounded implementation exception.
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and the supporting design artifacts. It does not claim application code already exists.
- The slice remains bounded to one read-only customer-safe workspace surface in the current admin plane. No new identity plane, persistence layer, review-generation workflow, remediation path, or raw-diagnostic default path is approved by this package.
- If implementation later proves `TenantReviewRegisterService` reuse insufficient or shows that explicit artifact access requires a new stable `AuditActionId`, that must be recorded as a bounded note in `Guardrail / Exception / Smoke Coverage` rather than silently widening the architecture.
## Implementation Close-out Addendum
- Implemented surface: native `CustomerReviewWorkspace` page and Blade view in the existing admin-plane reviews family, still reusing current tenant review, review-pack, evidence, artifact-truth, RBAC, and audit seams.
- T010 outcome: direct workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` remained acceptable reuse via existing row/detail navigation.
- T020 outcome: pack-download plumbing changed, so `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after request-time membership plus `REVIEW_PACK_VIEW` enforcement was added to the signed download route.
- T023 outcome: the current audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs. No new audit store was introduced.
- Smoke evidence outcome: the implementation close-out used the bounded Pest browser smoke plus the focused feature lane as executed smoke proof. No separate manual integrated-browser run was completed.
- Final review outcome class: `acceptable-special-case`.
- Final workflow outcome: `keep`.

View File

@ -1,261 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Customer Review Workspace v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the read-oriented customer-safe workspace review
surface planned by Spec 249.
NOTE: The canonical page is planned as a native Filament / Livewire page in
the existing admin plane. The JSON response shapes below describe the
derived workspace view model for planning purposes; they do not require a
new public REST API in v1.
servers:
- url: /
paths:
/admin/reviews/workspace:
get:
summary: View the customer review workspace
description: |
Canonical admin-plane workspace page for customer-safe review
consumption. The page stays read-only and derives its rows from
existing tenant review, review-pack, evidence, and entitlement truth.
parameters:
- in: query
name: tenant_id
required: false
schema:
type: integer
description: |
Optional tenant prefilter carried from an existing tenant-scoped
review, review-pack, evidence, or dashboard entry point.
- in: query
name: source
required: false
schema:
type: string
description: Optional launch-context hint used for page highlighting only.
responses:
'200':
description: Workspace page rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/CustomerReviewWorkspaceCollection'
'403':
description: Forbidden after workspace membership is established but required capability is missing
'404':
description: Not found for non-members or explicit out-of-scope tenant targeting
/admin/t/{tenant}/reviews/{review}:
get:
summary: Open the latest tenant review detail
description: |
Existing tenant-scoped review detail route reused as the primary inspect
action from the workspace page.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: review
required: true
schema:
type: integer
responses:
'200':
description: Tenant review detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an entitled member missing the review capability
'404':
description: Not found for non-members or tenant / review mismatches
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
get:
summary: Open evidence detail from the customer review flow
description: |
Existing tenant-scoped evidence detail route reused only as optional
proof when the actor explicitly asks for it and has the required
capability.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: evidenceSnapshot
required: true
schema:
type: integer
responses:
'200':
description: Evidence detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an entitled member missing the evidence capability
'404':
description: Not found for non-members or tenant / evidence mismatches
/admin/review-packs/{reviewPack}/download:
get:
summary: Download the current review pack
description: |
Existing signed download route reused by the workspace page. The pack
must already exist, be ready, and not be expired.
parameters:
- in: path
name: reviewPack
required: true
schema:
type: integer
responses:
'200':
description: Review pack download stream
content:
application/zip:
schema:
type: string
format: binary
'403':
description: Forbidden due to missing signature or invalid signed URL
'404':
description: Review pack not found, not ready, or expired
components:
schemas:
CustomerReviewWorkspaceCollection:
type: object
required:
- workspace_id
- entries
properties:
workspace_id:
type: integer
tenant_prefilter_id:
type: integer
nullable: true
highlighted_tenant_id:
type: integer
nullable: true
launch_source:
type: string
nullable: true
entries:
type: array
items:
$ref: '#/components/schemas/CustomerReviewWorkspaceEntry'
empty_state_message:
type: string
nullable: true
CustomerReviewWorkspaceEntry:
type: object
required:
- tenant_id
- tenant_name
- review_pack_available
properties:
tenant_id:
type: integer
tenant_name:
type: string
latest_published_review_id:
type: integer
nullable: true
latest_review_generated_at:
type: string
format: date-time
nullable: true
latest_review_published_at:
type: string
format: date-time
nullable: true
review_outcome_label:
type: string
nullable: true
review_outcome_explanation:
type: string
nullable: true
key_findings_summary:
$ref: '#/components/schemas/FindingsSummary'
accepted_risk_summary:
$ref: '#/components/schemas/AcceptedRiskSummary'
review_pack:
$ref: '#/components/schemas/ReviewPackAccess'
evidence_proof:
$ref: '#/components/schemas/EvidenceProof'
primary_review_url:
type: string
nullable: true
redaction_note:
type: string
nullable: true
absence_note:
type: string
nullable: true
FindingsSummary:
type: object
nullable: true
properties:
total_visible:
type: integer
attention_required_count:
type: integer
summary_text:
type: string
AcceptedRiskSummary:
type: object
nullable: true
properties:
accepted_count:
type: integer
follow_up_required_count:
type: integer
summary_text:
type: string
ReviewPackAccess:
type: object
required:
- available
properties:
available:
type: boolean
review_pack_id:
type: integer
nullable: true
download_url:
type: string
nullable: true
status_message:
type: string
nullable: true
EvidenceProof:
type: object
nullable: true
properties:
evidence_snapshot_id:
type: integer
nullable: true
detail_url:
type: string
nullable: true
freshness_label:
type: string
nullable: true

View File

@ -1,210 +0,0 @@
# Data Model — Customer Review Workspace v1
**Spec**: [spec.md](spec.md)
No new persisted tables or customer-review entities are required for v1. The feature reuses existing tenant-owned review, review-pack, evidence, findings, and audit truth, then derives one workspace-scoped read model for page presentation.
## Persisted Truth Reused
### Workspace / Tenant Entitlement Context
**Purpose**: Establish the current workspace boundary and the entitled tenant set before any review rows are composed.
**Persisted carriers**:
- existing workspace membership records
- existing tenant membership pivot records and tenant role assignments
- existing capability registry and role-capability map
**Relevant fields / contracts**:
- `workspace_id`
- `tenant_id`
- tenant membership role
- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php)
**Validation rules**:
- current actor must be a member of the current workspace or the page resolves as not found
- tenant rows may only be composed for tenants in the current workspace where the actor is entitled through the canonical role-capability map
- no cross-workspace or cross-tenant fallback lookups are allowed
### TenantReview
**Purpose**: Canonical source for the latest customer-safe review posture, summary text, findings summary, accepted-risk summary, and primary inspect target.
**Persisted carrier**: existing `tenant_reviews` rows via `TenantReview`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `generated_at`
- `published_at`
- `summary`
- `evidence_snapshot_id`
- `current_export_review_pack_id`
- `tenant`
- `evidenceSnapshot`
- `currentExportReviewPack`
- `sections`
**Validation / usage rules**:
- the default customer-safe path uses the latest published review per entitled tenant
- draft, ready, failed, archived, and superseded reviews stay off the default-visible page summary unless explicitly reused as internal proof elsewhere
- summary data already shaped into the review artifact remains the preferred source for findings and review-level posture messaging
### TenantReviewSection
**Purpose**: Supporting persisted proof for accepted-risk and section-level disclosure without introducing a new workspace summary store.
**Persisted carrier**: existing `tenant_review_sections` rows
**Relevant fields / relationships**:
- `tenant_review_id`
- `section_key`
- `title`
- `completeness_state`
- `summary_payload`
- `render_payload`
**Validation / usage rules**:
- accepted-risk summaries should come from the existing review section payloads that were already composed for the review artifact
- section payload reuse must remain read-only and redaction-safe
### ReviewPack
**Purpose**: Canonical packaged artifact for customer-safe review consumption and download.
**Persisted carrier**: existing `review_packs` rows via `ReviewPack`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `tenant_review_id`
- `status`
- `generated_at`
- `expires_at`
- `summary`
- `file_path`
- `file_disk`
- `sha256`
- `tenantReview`
- `evidenceSnapshot`
**Validation / usage rules**:
- download is available only when the current pack is ready and not expired
- the workspace page may surface availability and a signed download path only when `REVIEW_PACK_VIEW` applies
- the workspace page must not start generation, regeneration, or recovery flows
### EvidenceSnapshot
**Purpose**: Existing proof artifact for freshness and evidence completeness when the actor explicitly asks for supporting detail.
**Persisted carrier**: existing `evidence_snapshots` rows via `EvidenceSnapshot`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `completeness_state`
- `generated_at`
- `expires_at`
- `summary`
- `items`
**Validation / usage rules**:
- evidence detail is not part of the default-visible customer path
- drilldown remains explicit and capability-gated by `EVIDENCE_VIEW`
- evidence truth remains tenant-owned and derived from the existing snapshot lifecycle
### Audit Log Event Family
**Purpose**: Existing audit truth for explicit review-artifact access and download actions.
**Persisted carrier**: existing `audit_logs` rows via `WorkspaceAuditLogger`
**Relevant fields / contracts**:
- stable `AuditActionId`
- `workspace_id`
- optional `tenant_id`
- actor metadata
- target resource type / id / label
- action context metadata
**Validation / usage rules**:
- no new audit store is introduced
- explicit artifact open/download events should reuse the current audit pipeline
- page render itself should not become a noisy new audit family
## Derived Read Model
### CustomerReviewWorkspaceEntry
**Purpose**: Derived page row or card summarizing the latest customer-safe review state for one entitled tenant.
**Persistence**: none; computed at request time
**Fields**:
- `workspace_id`
- `tenant_id`
- `tenant_name`
- `latest_published_review_id` (nullable)
- `latest_review_generated_at` (nullable)
- `latest_review_published_at` (nullable)
- `review_outcome_label` (nullable, derived from existing artifact truth)
- `review_outcome_explanation` (nullable)
- `key_findings_summary` (nullable, derived from existing review summary)
- `accepted_risk_summary` (nullable, derived from existing review section payloads)
- `review_pack_id` (nullable)
- `review_pack_available` (boolean)
- `review_pack_status_note` (nullable derived string)
- `evidence_snapshot_id` (nullable)
- `primary_review_url` (nullable)
- `review_pack_download_url` (nullable)
- `evidence_detail_url` (nullable)
- `redaction_note` (nullable)
- `absence_note` (nullable derived string)
**Derivation rules**:
- exactly one entry exists per entitled tenant visible in the current workspace scope
- when a published review exists, the entry derives customer-safe posture from that latest published review only
- when no published review exists for an entitled tenant, the entry may carry a derived absence note such as `No published review available yet`; this remains view logic, not domain state
- raw JSON, raw provider payloads, unrestricted audit metadata, fingerprints, and debug-only context are never part of the entry
**Validation rules**:
- entries may only be built for tenants in the current workspace and current entitlement scope
- `review_pack_download_url` is present only when a current pack exists and the actor can view it
- `evidence_detail_url` is present only when the actor can view evidence detail
- absence or unavailable wording must not hint at hidden drafts or hidden operator-only artifacts
## Request-Scoped Page State
### CustomerReviewWorkspaceState
**Purpose**: Livewire-safe page state carrying tenant launch context and remembered filters.
**Persistence**: request, query, and session-backed page state only
**Fields**:
- `tenant_id` (nullable requested prefilter)
- `highlight_tenant_id` (nullable)
- `launch_source` (nullable string such as review, review_pack, evidence, or dashboard)
- `search` (nullable)
- `tableFilters` (session-backed when the implementation uses table filters)
**Validation rules**:
- requested tenant filters must resolve to an entitled tenant or the page should respond as not found for explicit tenant targeting
- state that needs to survive Livewire interactions must remain hydrated public or query/session-backed state
- if implementation adds a secondary status filter, it must operate on customer-safe derived labels only, not raw internal lifecycle states
## State Transition Summary
This slice introduces no new persisted lifecycle or status family. Only derived page-state transitions are expected:
- default workspace view -> tenant-prefiltered view
- tenant-prefiltered view -> cleared workspace view
- published review available -> inspect or download action available subject to capability checks
- no published review available -> truthful absence message only
No queue, publish, generate, regenerate, remediate, or archive transition belongs to this page.

View File

@ -1,310 +0,0 @@
# Implementation Plan: Customer Review Workspace v1
**Branch**: `249-customer-review-workspace` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce one canonical customer-safe review workspace inside the existing `/admin` plane by adding a native Filament v5 read-only page that derives its content from existing tenant review, review-pack, evidence, findings, redaction, and audit truth. The page should answer the first customer question quickly, then reuse the existing tenant-scoped review, review-pack, and evidence detail routes for proof instead of creating a new truth layer.
This slice is explicitly consumption-only. It does not create or publish reviews, generate or regenerate review packs, remediate findings, widen the identity model, or add persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new globally searchable resource is introduced, and no new asset bundle is expected for v1.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
**Storage**: PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned
**Testing**: Pest v4 feature coverage plus one browser smoke slice, with optional narrow unit coverage only if a row-composition helper emerges during implementation
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces
**Project Type**: Web application (Laravel monolith with Filament panels)
**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; latest review lookup should stay within one eager-loaded workspace read path
**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; keep raw diagnostics and debug semantics out of the default path; avoid new persistence, new customer role families, and new presenter/taxonomy layers
**Scale/Scope**: 1 new admin page, 1 derived workspace summary per entitled tenant, reuse of 5 existing read surfaces and their services, 0 new runtime entities, and 1 explicit browser smoke slice
## Likely Affected Repo Surfaces
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) for the existing workspace review register pattern, filter behavior, and action-surface expectations.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) for canonical workspace-page state persistence, tenant-prefilter handling, and clickable-row read-only reporting patterns.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) as the preferred entitlement and workspace query seam to extend or reuse before adding any new helper.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) for existing tenant-scoped proof routes, action-surface rules, and capability gates.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php), [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php), and [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) for signed download generation, current pack availability semantics, and the real download route.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) plus the existing `TenantReview` summary/section payloads for published review truth, findings summaries, and accepted-risk source data.
- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for customer-safe outcome and redaction wording.
- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php), [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php), and existing tenant review / evidence policies for capability-first RBAC.
- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for audit reuse.
- Likely new implementation files if code work later proceeds: `App\Filament\Pages\Reviews\CustomerReviewWorkspace`, a matching Blade view under `resources/views/filament/pages/reviews/`, and focused tests under `tests/Feature/Reviews/` and `tests/Browser/Reviews/`.
## UI / Filament & Livewire Fit
- Implement as a native Filament v5 `Page` using the same `HasTable` / `InteractsWithTable` style already used by the workspace review and evidence overview pages. Do not introduce a new Resource, portal shell, custom SPA, or second panel.
- Keep the page in the existing admin-plane reporting family with one primary inspect affordance and, at most, one inline safe download shortcut. The workspace page itself should not expose bulk actions, More-menu sprawl, or lifecycle controls.
- Livewire v4 hydration must preserve tenant prefilter and launch-context state through public, query-backed, or session-backed state. Do not rely on private page properties for any state that must survive a Livewire interaction.
- Tenant detail links should continue using the existing tenant-scoped route helpers from the resource layer so the workspace page stays a navigation surface, not a duplicate detail renderer.
- The new surface is a Page, not a globally searchable Resource. Existing tenant review, review-pack, and evidence resources already have global search disabled, and that remains unchanged for this slice.
## RBAC / Policy Fit
- Workspace membership remains the first gate. The preferred access check is the existing workspace-entitlement path already used by `TenantReviewRegisterService::canAccessWorkspace(...)` and the current workspace context.
- The safe v1 audience anchor remains the existing readonly-capable tenant role in [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php). No new customer role family or external customer identity plane is planned.
- Page visibility and row composition should derive entitled tenants from the canonical capability registry: `TENANT_VIEW` plus `TENANT_REVIEW_VIEW` for page entry, with `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` gating optional secondary proof.
- Non-members or explicit out-of-scope tenant targets must resolve as not found. Once workspace and tenant membership are established, missing secondary capabilities remain normal authorization failures for execution paths and should not leak hidden content through the UI.
- Policy and gate checks stay capability-first. No role-string checks or customer-only bypasses should appear in the implementation.
## Audit / Logging Fit
- Reuse the existing audit infrastructure through `WorkspaceAuditLogger` and `AuditActionId`. The feature should not create a separate audit store, mirror page-view ledger, or custom analytics table.
- Existing review export generation already logs `tenant_review.exported`, and review-pack download already has a real signed route. The plan assumes explicit customer-facing artifact open/download events can remain on the current audit pipeline.
- If the workspace page needs a distinct access event beyond what current review-pack or review actions already capture, add a stable `AuditActionId` case and log it through the shared audit path rather than page-local ad hoc logging.
- Default page render should not emit noisy audit events. The auditable boundary is explicit artifact access or download, not passive page paint.
## Data & Query Fit
- The preferred row source is a derived workspace read over entitled tenants and their latest published `TenantReview`, with eager-loaded `tenant`, `evidenceSnapshot`, and `currentExportReviewPack` relations.
- Accepted-risk and findings summaries should come from existing review summary / section payloads, not from a new customer-specific aggregation model. `TenantReviewSectionFactory` already shapes accepted-risk and finding outcome data into the review artifact.
- Absence handling must remain derived view logic. The page may surface `No published review available yet` or pack-unavailable messaging, but that language must not become a new persisted lifecycle or publication taxonomy.
- No new table, cache, or materialized view is planned. If the existing register service cannot express the exact latest-published-per-tenant query safely, extend that service or add a bounded page-local read helper rather than introducing a new projector or presenter family.
- Pack availability should remain tied to the current review/export relationship and existing signed download semantics. The page must not trigger generation or regeneration.
## UI / Surface Guardrail Plan
> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.**
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: reporting, evidence/report viewers, navigation entry points, review/download actions, disclosure hierarchy
- **State layers in scope**: page, URL-query
- **Audience modes in scope**: customer/read-only, operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: capability-gated and hidden by default through reused detail routes only
- **One-primary-action / duplicate-truth control**: the dominant next action remains `Open latest review`; download is the only inline safe shortcut, and deeper proof stays on existing detail surfaces instead of being repeated on the workspace page
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now, future hard-stop candidate if implementation introduces a second customer-review truth path
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, bounded-browser-smoke
- **Exception path and spread control**: none expected; any need for a local presenter, custom disclosure taxonomy, or new detail shell should be treated as exception-required drift
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
## Shared Pattern & System Fit
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
- **Cross-cutting feature marker**: yes
- **Systems touched**: `ReviewRegister`, `EvidenceOverview`, `TenantReviewRegisterService`, `TenantReviewResource`, `ReviewPackResource`, `EvidenceSnapshotResource`, `ReviewPackService`, `TenantReviewService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId`, and the existing capability / policy seams
- **Shared abstractions reused**: `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, Filament action-surface declarations, existing tenant-scoped resource URL helpers, `ReviewPackService`, `RedactionIntegrity`, and the existing audit pipeline
- **New abstraction introduced? why?**: none by default. If implementation discovers that the current register service cannot safely express latest-published-per-tenant rows, the smallest acceptable addition is a bounded read helper or service extension for this page only
- **Why the existing abstraction was sufficient or insufficient**: current review, evidence, and pack abstractions already hold the truth and disclosure language; they are insufficient only because there is no calm customer-safe workspace entry point today
- **Bounded deviation / spread control**: none planned. The new page must compose existing truth, not rename it or mirror it into a customer-specific presenter framework
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: read-only inspection and signed artifact download only; any existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default customer path
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing workspace, tenant, review, evidence, findings, and audit vocabulary only
- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `review`, `evidence`, `review pack`, `accepted risk`, and existing artifact-truth wording
- **Retained provider-specific semantics and why**: none; the feature consumes provider-shaped artifacts only through already-normalized platform surfaces
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The slice consumes existing `TenantReview`, `ReviewPack`, and `EvidenceSnapshot` artifacts as read-only truth and does not redefine source-of-truth boundaries.
- Read/write separation: PASS. The workspace page is read-only and adds no create, publish, regenerate, expire, triage, or remediation action. Any destructive-like action that already exists on reused detail pages remains outside the default path and must continue using confirmation.
- Graph contract path: PASS. No new Graph calls or provider contract work are part of this slice.
- Deterministic capabilities: PASS. The plan reuses the canonical capability registry in [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) and the existing role map.
- Workspace isolation + tenant isolation: PASS. Workspace membership remains a 404 boundary, tenant entitlement remains a 404 boundary, and explicit out-of-scope tenant filters must not leak existence.
- RBAC-UX plane separation: PASS. Everything stays in the existing `/admin` plane and tenant-scoped detail routes; no `/system` surface or cross-plane flow is added.
- Destructive confirmation standard: PASS. No destructive action is planned on the workspace page. If implementation later discovers any destructive affordance on a reused surface must be exposed, it must remain confirmation-protected and out of the default customer-safe path.
- Global search safety: PASS. The new slice is a Page, not a Resource. Existing tenant review, review-pack, and evidence resources are already not globally searchable, and no new searchable resource is introduced.
- OperationRun and Ops-UX: PASS by non-use. The workspace page starts no run, emits no run UX, and performs no queue orchestration.
- Data minimization: PASS. Default-visible content stays decision-first; raw JSON, unrestricted audit metadata, fingerprints, debug semantics, and raw provider payloads stay hidden.
- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused feature tests plus one bounded browser smoke slice, with optional unit coverage only if a local read helper appears.
- Proportionality / no premature abstraction: PASS. No new persistence, presenter family, enum family, or identity plane is planned; the narrowest shape is one page over existing truth seams.
- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored customer-review projection is planned.
- Behavioral state (STATE-001): PASS. Any unavailable or no-published-review wording remains derived UI state, not a new persisted lifecycle.
- UI semantics / shared pattern first / Filament-native UI: PASS. The plan reuses Filament pages/resources, existing badge and artifact-truth presenters, existing download service, and the current disclosure language rather than inventing a new UI framework.
- Provider boundary (PROV-001): PASS. The slice stays within already-normalized review and evidence artifacts and does not deepen provider coupling.
- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel registration is needed, and no new panel-only or shared asset registration is expected.
- Asset strategy: PASS. Default assumption is no new assets. If implementation later registers any Filament asset anyway, deployment continues to require `cd apps/platform && php artisan filament:assets`.
**Gate evaluation**: PASS.
- The slice stays inside the existing admin plane and current workspace/tenant membership model.
- The page remains a customer-safe consumption surface, not a new review-generation or remediation workflow.
- Existing review, evidence, pack, redaction, and audit seams are sufficient for v1 if the implementation resists adding a second presenter or persistence layer.
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)).
## Test Governance Check
> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.**
- **Test purpose / classification by changed surface**: Feature for workspace-page behavior, authorization, and pack-access semantics; Browser for the calm customer-safe disclosure smoke path; optional Unit only if a bounded row-composition helper is extracted
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: feature coverage is the cheapest way to prove deny-as-not-found behavior, capability-gated secondary actions, empty states, and signed download wiring on a native Filament page; one browser smoke is justified because the product value of this slice is the disclosure experience itself
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, tenant membership, published review, ready pack, evidence snapshot, findings, and finding-exception fixtures rather than introducing heavy new helpers
- **Expensive defaults or shared helper growth introduced?**: no; any helper added for row composition or launch-context state should stay explicit and page-local
- **Heavy-family additions, promotions, or visibility changes**: exactly one browser smoke slice only; no broader browser family or heavy-governance lane expansion should be needed
- **Surface-class relief / special coverage rule**: standard-native-filament relief for route, auth, filters, and empty states; shared-detail-family checks only where navigation into existing review/pack/evidence surfaces needs proof
- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page never shows admin or remediation controls by default, verify out-of-scope tenant targeting stays 404-safe, and verify download remains signed and capability-bound through the existing pack path
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local browser cost
- **Review-stop questions**: lane fit, hidden fixture cost, accidental browser family growth, duplicated detail rendering, audit proof adequacy
- **Escalation path**: `document-in-feature` for contained audit-test placement drift; `reject-or-split` if implementation grows into write workflows, new persistence, or a larger browser suite
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: routine test and disclosure upkeep stays inside this feature unless implementation proves a structural need for a broader customer-access program
## Rollout & Risk Controls
- Keep the v1 audience anchored to the current readonly-capable tenant role plus existing review/evidence capabilities. No navigation or deep link should become visible without those gates.
- Treat the page as a new read-only entry point only. Do not move generation, publish, regenerate, refresh, expire, triage, or remediation controls onto it during implementation.
- Prefer extending the existing register and detail seams over introducing any persisted projection, local presenter family, or customer-only vocabulary.
- Keep signed pack download on the existing route and controller path. Do not replace it with a new download endpoint just for the workspace page.
- Validate the page with one browser smoke before considering any broader navigation prominence changes. The rollout risk is disclosure drift, not infrastructure change.
- No migration, queue worker change, or asset build sequence change is expected for this slice.
## Project Structure
### Documentation (this feature)
```text
specs/249-customer-review-workspace/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── customer-review-workspace.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Reviews/
│ │ │ ├── ReviewRegister.php
│ │ │ └── CustomerReviewWorkspace.php # likely new page if implementation proceeds
│ │ ├── Pages/Monitoring/EvidenceOverview.php
│ │ └── Resources/
│ │ ├── TenantReviewResource.php
│ │ ├── ReviewPackResource.php
│ │ └── EvidenceSnapshotResource.php
│ ├── Http/Controllers/ReviewPackDownloadController.php
│ ├── Models/TenantReview.php
│ ├── Services/
│ │ ├── Audit/WorkspaceAuditLogger.php
│ │ ├── ReviewPackService.php
│ │ └── TenantReviews/
│ │ ├── TenantReviewRegisterService.php
│ │ └── TenantReviewService.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ ├── Auth/Capabilities.php
│ │ ├── RedactionIntegrity.php
│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
│ └── Policies/
│ ├── TenantReviewPolicy.php
│ └── EvidenceSnapshotPolicy.php
├── bootstrap/providers.php
├── resources/views/filament/pages/reviews/ # likely new page view if implementation proceeds
├── routes/web.php
└── tests/
├── Browser/Reviews/
├── Feature/Reviews/
└── Feature/ReviewPack/
```
**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching Blade view, and reuse the existing review, pack, evidence, RBAC, and audit seams rather than creating a separate customer-facing subsystem.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None expected | The intended implementation is one native page plus derived queries over existing truth | A separate portal, persistence layer, or customer presenter framework would import unnecessary scope and ownership cost |
## Proportionality Review
- **Current operator problem**: review artifacts already exist, but readonly-capable tenant actors still lack one coherent customer-safe workspace surface to consume them.
- **Existing structure is insufficient because**: current review, pack, and evidence surfaces are truthful but fragmented and more operator-oriented than a calm customer-first entry point.
- **Narrowest correct implementation**: add one native Filament page over existing tenant review, review-pack, evidence, findings, redaction, and audit seams, with only derived row composition and no new persistence.
- **Ownership cost created**: one page, one view, one bounded query/composition seam if required, and focused feature/browser tests.
- **Alternative intentionally rejected**: a new customer portal, a new identity plane, and a new persisted customer-review projection were all rejected because existing admin-plane RBAC and review artifacts are sufficient for the first slice.
- **Release truth**: current-release customer-safe consumption slice, not future-release portal preparation.
## Phase 0 — Research (output: research.md)
Research resolves the remaining implementation-shaping decisions:
- place the new page in the existing admin-plane reviews family rather than extending `ReviewRegister` into a dual-persona page or creating a portal shell
- reuse `TenantReviewRegisterService` as the entitlement/query seam before adding any new helper
- keep tenant prefilter and launch context in Livewire-safe public/query/session-backed state
- reuse `ArtifactTruthPresenter`, existing review summary payloads, and `RedactionIntegrity` for disclosure instead of adding a customer presenter layer
- reuse the existing signed review-pack download route and audit pipeline rather than inventing a new consumption endpoint
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow implementation shape:
- no new persistence; reused truth stays in tenant reviews, review packs, evidence snapshots, findings/exceptions, memberships, and audit logs
- one derived workspace entry model documents what the new page must compose without becoming a stored entity
- the conceptual contract documents the page route, tenant-detail handoff, and signed pack-download semantics
- quickstart records the intended implementation order, validation commands, Filament/Livewire assumptions, provider-registration location, and no-new-assets posture
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)
- [quickstart.md](quickstart.md)
## Phase 2 — Planning (for tasks.md)
Dependency-ordered implementation outline for the later `tasks.md` step:
1. Add a native `CustomerReviewWorkspace` admin page and Blade view in the reviews family, keeping the action surface read-only and customer-safe.
2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace access, entitled tenants, and the latest published review per entitled tenant with the required eager loads.
3. Compose row content from existing review summary / section payloads, `ArtifactTruthPresenter`, current export review-pack relationships, and `RedactionIntegrity` notes, without creating a new presenter or persistence layer.
4. Preserve launch-context tenant prefiltering and Livewire-safe filter state using the same workspace-page state patterns already proven in `ReviewRegister` and `EvidenceOverview`.
5. Wire the dominant inspect action to the existing tenant-scoped review detail route and keep review-pack download on the current signed route; evidence drilldown remains explicit and capability-gated.
6. Reuse the audit pipeline for explicit artifact access or download events only if the current path does not already emit a truthful stable event; do not add a new audit store.
7. Add focused feature coverage for page behavior, authorization, and pack access, then one browser smoke test for calm disclosure and absence of admin controls. Run Pint after implementation.
## Guardrail / Exception / Smoke Coverage
- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the feature adds no new globally searchable Resource, no destructive action on the workspace page, and no new asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are added.
- Shared seam outcome: `TenantReviewRegisterService` remained the entitlement and latest-published query seam. No local helper or second customer-review truth layer was introduced.
- Launch-path outcome: direct customer-workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` were satisfied through existing row/detail navigation reuse instead of duplicate workspace buttons.
- Read-only detail outcome: the workspace handoff now appends a dedicated customer-workspace context query flag, and `ViewTenantReview` suppresses management actions in that customer-safe flow while preserving the established operator detail route behavior outside that flow.
- Pack-download outcome: the existing signed route was retained, but it now also enforces tenant membership plus `REVIEW_PACK_VIEW` at request time. That touched download plumbing and required `ReviewPackDownloadTest.php` plus `ReviewPackRbacTest.php` updates.
- Audit outcome: the existing audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs logged through `WorkspaceAuditLogger`. No new audit store or parallel logging path was introduced.
- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` passed with `83 passed (372 assertions)`.
- Smoke evidence: the executed smoke proof was the bounded Pest browser harness in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`, which passed with `1 passed (19 assertions)`. No separate manual integrated-browser run was performed.
- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- Review outcome class: `acceptable-special-case`.
- Workflow outcome: `keep`.
- Exception note: none. The implementation stayed within the planned admin-plane, read-only, shared-primitives-first shape.

View File

@ -1,59 +0,0 @@
# Quickstart — Customer Review Workspace v1
## Preconditions
- Docker is running and the Sail stack for `apps/platform` is available.
- The feature remains inside the existing Laravel monolith and admin plane.
- The first slice stays read-oriented: no new customer portal, no new identity plane, no new persistence, and no remediation or generation workflow.
## Intended Implementation Order
1. Add the native admin `CustomerReviewWorkspace` page and its Blade view under the existing reviews family.
2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace membership, entitled tenants, and latest published reviews per entitled tenant.
3. Compose customer-safe row content from existing `TenantReview` summary / section payloads, `ArtifactTruthPresenter`, `currentExportReviewPack`, and `RedactionIntegrity`.
4. Preserve tenant launch context and remembered filters through Livewire-safe public/query/session-backed state.
5. Wire `Open latest review` to the existing tenant-scoped review detail route and keep review-pack consumption on the existing signed download path.
6. Reuse the existing audit pipeline for any explicit artifact access event that is not already covered by the current review / export flow.
7. Add focused feature coverage and one browser smoke test, then run Pint.
## Targeted Validation Commands (after implementation)
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- If implementation changes pack-download plumbing directly: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Smoke Checklist Reference (after implementation)
Implementation close-out used the bounded browser smoke in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` plus the focused feature lane as the executed smoke evidence. The checklist below remains the human reference checklist, but no separate manual integrated-browser run was executed for this implementation close-out.
1. Sign in to `/admin` as a readonly-capable tenant actor, select a workspace, and open `/admin/reviews/workspace`.
2. Confirm that the page shows only entitled tenants, the latest customer-safe review posture, and no create, publish, regenerate, refresh, expire, triage, or remediation controls.
3. Launch the page from an existing tenant-scoped review or evidence route and confirm the tenant prefilter survives the first page load.
4. Open the latest review for a tenant with a published review and confirm the detail remains read-oriented for the readonly actor.
5. Use the pack action for a tenant with a current pack and confirm the download path stays signed and customer-safe; for a tenant without a current pack, confirm the page shows a calm unavailable state instead of a generation action.
6. Attempt an explicit out-of-scope tenant filter or deep link and confirm the result stays not found without leaking tenant existence.
## Executed Validation Evidence
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `83 passed (372 assertions)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `1 passed (19 assertions)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -> `pass`
## Close-out Notes
- `TenantReviewRegisterService` reuse held; no page-local helper was needed.
- The review-pack download route remained signed, but now also enforces tenant membership and `REVIEW_PACK_VIEW` at request time.
- Explicit artifact access is now audited through additive `tenant_review.opened` and `review_pack.downloaded` action IDs on the existing audit pipeline.
- `ReviewRegister` and `EvidenceOverview` satisfied the launch-path requirement through existing row/detail navigation reuse rather than new duplicate workspace buttons.
## Notes
- Filament v5 already runs on Livewire v4 in this repo.
- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); this slice does not add or move providers.
- No new globally searchable Resource is part of v1. Existing review, review-pack, and evidence Resources already keep global search disabled.
- No destructive action belongs on the new workspace page. If implementation accidentally introduces one, it must use `->requiresConfirmation()` and stay outside the customer-safe default path.
- No new registered asset bundle is expected. If implementation later registers a Filament asset anyway, deployment still requires `cd apps/platform && php artisan filament:assets`.
- This remains a customer-safe consumption slice only. Review creation, publication, regeneration, remediation, and operator/debug workflows remain on existing internal surfaces or future specs.

View File

@ -1,166 +0,0 @@
# Research — Customer Review Workspace v1
**Date**: 2026-04-27
**Spec**: [spec.md](spec.md)
This document resolves the planning decisions that shape the smallest safe implementation slice for Spec 249.
## Decision 1 — Place the new surface as a native admin reviews page
**Decision**: Implement the customer-safe workspace as a new native Filament page under the existing admin reviews family, with the planned route shape `/admin/reviews/workspace`. Do not create a new panel, a public/customer portal shell, or a new Resource just to host the view.
**Rationale**:
- The repo already has native workspace-level read-only pages for reporting and monitoring.
- The existing review, review-pack, and evidence Resources already own tenant-scoped detail and proof routes.
- A dedicated page keeps the first slice calm and customer-safe without overloading an operator-oriented registry.
**Evidence**:
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) already provides the workspace review register pattern.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) already provides a workspace-scoped read-only page pattern with tenant prefilters.
- [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) already registers the existing panel providers. No new provider registration is needed.
**Alternatives considered**:
- Extend `ReviewRegister` into a dual-persona page.
- Rejected: it already carries operator-oriented filters and export semantics, which would blur the customer-safe default path.
- Create a new customer portal or new identity plane.
- Rejected: outside the bounded v1 scope and unnecessary because the current admin plane plus readonly-capable roles already exists.
## Decision 2 — Reuse `TenantReviewRegisterService` as the entitlement and query seam
**Decision**: Prefer extending or reusing [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) for workspace access checks, entitled-tenant discovery, and the base review query before adding any new helper.
**Rationale**:
- The service already centralizes workspace membership and entitled tenant selection for the current review register.
- Reusing it keeps entitlement logic in one place and avoids new raw tenant-role queries inside the page.
- It is the narrowest existing seam that can be extended toward latest-published-per-tenant behavior.
**Evidence**:
- `authorizedTenants(...)` already derives tenant scope from the canonical role/capability map.
- `query(...)` already scopes tenant reviews to the current workspace and eager-loads `tenant`, `evidenceSnapshot`, and `currentExportReviewPack`.
- `canAccessWorkspace(...)` already exposes the workspace-membership check needed for deny-as-not-found page gating.
**Alternatives considered**:
- Build a new persisted workspace summary table.
- Rejected: violates the no-new-persistence rule for a derived read surface.
- Recreate entitlement logic directly inside the page class.
- Rejected: duplicates existing membership and capability behavior.
## Decision 3 — Derive the default path from the latest published tenant review per entitled tenant
**Decision**: The default workspace page should derive each tenant summary from the latest published `TenantReview` for that entitled tenant, with eager-loaded `currentExportReviewPack` and `evidenceSnapshot` relationships.
**Rationale**:
- The spec requires the default path to stay customer-safe and exclude draft, failed, and other internal-only states.
- The current `TenantReview` model already distinguishes published reviews and holds the summary relationships the new page needs.
- This keeps the page read-oriented and avoids a separate customer-review lifecycle.
**Evidence**:
- [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) already exposes `published()` and `currentExportReviewPack()`.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already stores review summary payloads, evidence basis, and export readiness.
- Existing review composition already emits `finding_outcomes` and accepted-risk related payloads through the review artifact family.
**Alternatives considered**:
- Surface draft or ready reviews when no published review exists.
- Rejected: leaks internal lifecycle meaning into the customer-safe path.
- Create a second customer-review publication model.
- Rejected: duplicates review truth and imports unnecessary workflow complexity.
## Decision 4 — Keep page state Livewire-safe and tenant-prefilter aware
**Decision**: Tenant launch context, requested tenant filters, and any remembered page state must live in public, query-backed, or session-backed state, following the existing workspace-page patterns. Do not keep required filter state in private properties.
**Rationale**:
- Existing workspace pages already show how canonical admin pages preserve tenant prefilters and survive Livewire follow-up requests.
- This repo has already hit Livewire state-reset issues when tenant context lived in non-hydrated private properties.
- The workspace page needs tenant prefiltering from review, evidence, and related entry points.
**Evidence**:
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) uses canonical admin filter-state sync on mount.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) documents its page-state contract for tenant prefilters and remembered search/filter state.
- Repo memory already records that private page state can reset during Livewire actions on admin canonical pages.
**Alternatives considered**:
- Keep launch context in a private property only.
- Rejected: too brittle across Livewire requests.
- Use only client-side state.
- Rejected: breaks server-side truth and shareable canonical page behavior.
## Decision 5 — Reuse artifact-truth and redaction seams for customer-safe disclosure
**Decision**: Reuse [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php), `SurfaceCompressionContext`, and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for outcome, freshness, and redaction-safe wording.
**Rationale**:
- These seams already normalize review, review-pack, and evidence truth into operator-safe summaries.
- Reusing them preserves vocabulary and prevents a second customer-review explanation system.
- `RedactionIntegrity` already owns the repos protected-value and support-diagnostics notes.
**Evidence**:
- `ArtifactTruthPresenter::for(...)` already supports `TenantReview`, `ReviewPack`, and `EvidenceSnapshot`.
- `ReviewRegister`, `EvidenceOverview`, `TenantReviewResource`, and `ReviewPackResource` already depend on these truth envelopes.
- `RedactionIntegrity` already defines reusable disclosure notes for protected values and support diagnostics.
**Alternatives considered**:
- Introduce a customer-only presenter or status taxonomy.
- Rejected: duplicates shared artifact truth and increases review drift risk.
- Inline page-local disclosure strings only.
- Rejected: likely to diverge from existing review and pack semantics.
## Decision 6 — Keep review-pack consumption on the existing signed download path
**Decision**: Pack consumption should stay on the existing signed route and download controller, with the workspace page only generating or surfacing the already-authorized download path through [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php).
**Rationale**:
- The repo already has a real signed download route and a dedicated download controller.
- Reusing that path keeps the new page consumption-only and avoids inventing a customer-specific download endpoint.
- The page must not trigger pack generation or regeneration.
**Evidence**:
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already generates signed download URLs.
- [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) already exposes `/admin/review-packs/{reviewPack}/download` as the signed download route.
- [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) already enforces pack readiness and expiry constraints.
**Alternatives considered**:
- Add a new workspace-page-specific download endpoint.
- Rejected: duplicates current signed download behavior.
- Offer generate/regenerate from the workspace page.
- Rejected: out of scope and not customer-safe for v1.
## Decision 7 — Reuse the current audit pipeline and add new action IDs only if needed
**Decision**: Reuse `WorkspaceAuditLogger` and `AuditActionId` for any explicit artifact access or download events surfaced by the new page, and only add new stable action IDs if the existing review/export path does not already provide a truthful event.
**Rationale**:
- The repo already has a canonical workspace-scoped audit path.
- This slice needs auditability for explicit artifact consumption, not a new access-analytics subsystem.
- Stable action IDs are preferable to page-local logging if an additional event is truly required.
**Evidence**:
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already logs review creation/refresh through `WorkspaceAuditLogger`.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already logs review-pack export activity.
- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) is the stable audit action registry.
**Alternatives considered**:
- Add a new customer-review audit table.
- Rejected: violates the no-new-persistence rule.
- Emit page-render audits for every visit.
- Rejected: too noisy and not aligned with the explicit-artifact-access requirement.
## Decision 8 — Keep the slice Filament-native, asset-light, and non-searchable
**Decision**: Keep the slice on the existing Filament v5 / Livewire v4 stack, do not add a new Resource or global-search entry, and plan for no new asset bundle unless implementation proves otherwise.
**Rationale**:
- The feature is a new page over existing truth, not a new object family.
- Existing review, pack, and evidence Resources already disable global search because they are tenant-scoped.
- A native page avoids a second shell and keeps the deploy story unchanged.
**Evidence**:
- `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` already set global search off.
- Panel providers are already registered in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php).
- The repos Filament guidance already expects provider registration to remain in `bootstrap/providers.php` and assets to stay minimal unless explicitly registered.
**Alternatives considered**:
- Add a new searchable Resource just for the workspace page.
- Rejected: the surface is a page-level dashboard, not a new record type.
- Add a custom asset bundle or custom portal shell up front.
- Rejected: unnecessary for the first read-only slice.

View File

@ -1,299 +0,0 @@
# Feature Specification: Customer Review Workspace v1
**Feature Branch**: `249-customer-review-workspace`
**Created**: 2026-04-27
**Status**: Draft
**Input**: User description: "Prepare the Spec Kit feature for Customer Review Workspace v1 as the smallest customer-safe read-only review consumption slice in the existing admin plane, reusing current review, evidence, review-pack, RBAC, redaction, and audit truth without inventing a new customer portal or remediation flow."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has strong tenant review, evidence snapshot, and review-pack foundations, but customers and readonly-capable tenant actors still lack one calm, trustworthy workspace surface to consume the latest review state without being dropped into operator-heavy reporting detail.
- **Today's failure**: The product can generate review artifacts, but it cannot yet present them as a clearly customer-safe, read-only review experience. That leaves a sellable release gap and risks pushing readonly actors toward internal surfaces with too much operator context or unclear next steps.
- **User-visible improvement**: An authorized readonly-capable actor can open one workspace review surface, see the latest customer-safe review state per entitled tenant, understand key findings and accepted risks, and open or download existing review artifacts without seeing admin or remediation controls.
- **Smallest enterprise-capable version**: One canonical read-only workspace review page in the current `/admin` plane, defaulting to the latest published customer-safe review per entitled tenant, with calm outcome summaries, accepted-risk visibility, existing review-pack consumption, redaction-safe disclosure, and explicit absence of admin/remediation actions.
- **Explicit non-goals**: No new customer portal, no new identity plane, no new persistence model, no review authoring or publishing workflow, no remediation or exception editing, no review-pack generation/regeneration flow, no support desk workflow, no broad cross-tenant decision inbox, and no raw JSON or platform-debug surface.
- **Permanent complexity imported**: One new canonical read-only page, one bounded derived workspace projection over existing review/evidence/review-pack truth, focused authorization and audit coverage, and one explicit browser smoke slice for customer-safe disclosure.
- **Why now**: The implementation ledger marks this as a P0 release blocker. Existing review strength is real, but customer-safe review consumption is still the clearest missing sellable surface in the current queue.
- **Why not local**: Reusing isolated links into `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` without a canonical workspace entry point would preserve the current fragmentation and would not create a truthful customer-safe default path.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Multi-surface reuse and customer-facing wording. Defense: the slice stays inside the existing admin plane, imports no new persistence or identity system, reuses current artifact truth and RBAC seams, and explicitly forbids write paths.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- new canonical admin route for a read-only customer review workspace under `/admin/reviews/workspace`
- existing `/admin/reviews` workspace review register on `App\Filament\Pages\Reviews\ReviewRegister` as supporting context, not the primary customer-safe path
- existing tenant-scoped review detail on `App\Filament\Resources\TenantReviewResource`
- existing tenant-scoped review-pack detail/download on `App\Filament\Resources\ReviewPackResource`
- existing tenant-scoped evidence detail on `App\Filament\Resources\EvidenceSnapshotResource`
- **Data Ownership**: All consumed truth remains tenant-owned and derived from existing `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, finding/exception, and audit records bound to the current workspace and tenant. No new workspace-owned customer-review table, cache, mirror entity, or publication store is introduced.
- **RBAC**:
- workspace membership remains the first isolation boundary
- page entry requires established workspace scope plus at least one entitled tenant where the actor has `Capabilities::TENANT_VIEW` and `Capabilities::TENANT_REVIEW_VIEW`
- tenant rows and deep links only render for tenants the actor can access in the current workspace
- review-pack download remains gated by `Capabilities::REVIEW_PACK_VIEW`
- evidence drilldown remains gated by `Capabilities::EVIDENCE_VIEW`
- findings and accepted-risk sections reuse `Capabilities::TENANT_FINDINGS_VIEW` and `Capabilities::FINDING_EXCEPTION_VIEW`
- audit-related secondary disclosure, if present, remains gated by `Capabilities::AUDIT_VIEW`
- no new role family or customer identity plane is introduced; existing readonly-capable roles in `App\Services\Auth\RoleCapabilityMap` remain authoritative for v1
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped review, review-pack, evidence, or tenant dashboard surface, the workspace page prefilters to that tenant and highlights its latest customer-safe review first. Without a launch context, it shows all entitled tenants in the current workspace.
- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace membership is checked before page render. Tenant-scoped rows, summaries, and deep links are resolved only for tenants where the actor is both a workspace member and tenant-entitled. Explicit tenant filters or record opens that reference an inaccessible tenant resolve as not found rather than showing an empty hint.
## 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)**: evidence/report viewers, status messaging, navigation entry points, review/download actions, and artifact-truth presentation
- **Systems touched**: `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Pages\Monitoring\EvidenceOverview`, `App\Filament\Resources\TenantReviewResource`, `App\Filament\Resources\ReviewPackResource`, `App\Filament\Resources\EvidenceSnapshotResource`, `App\Services\ReviewPackService`, `App\Services\TenantReviews\TenantReviewService`, `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`, `App\Support\RedactionIntegrity`, `App\Support\OperationRunLinks`, existing audit infrastructure, and tenant/workspace authorization seams
- **Existing pattern(s) to extend**: current read-only registry/detail reporting surfaces, existing governance artifact truth envelopes, existing review-pack download semantics, existing redaction notes, and existing workspace/tenant-scoped navigation patterns
- **Shared contract / presenter / builder / renderer to reuse**: `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `ReviewPackService`, `RedactionIntegrity`, and existing tenant-scoped resource view surfaces
- **Why the existing shared path is sufficient or insufficient**: Existing review/evidence/review-pack surfaces already provide the underlying truth, disclosure semantics, and safe detail rendering. They are insufficient only because they do not offer one calm workspace entry point oriented around customer-safe consumption. The feature should add that entry point, not a parallel truth layer.
- **Allowed deviation and why**: none. The new page must reuse current truth, badge, redaction, and download language instead of inventing a second customer-review vocabulary.
- **Consistency impact**: Outcome, freshness, accepted-risk, pack-availability, and redaction notes must keep the same meaning across the new workspace page and the reused review, evidence, and review-pack detail surfaces.
- **Review focus**: Reviewers must block any new page-local status taxonomy, raw-payload viewer, or customer-specific mirror presenter that duplicates the existing review and artifact truth contracts.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: The workspace page is read-only. Existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default-visible customer path.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary is widened. The feature consumes existing review and evidence artifacts without introducing new provider-shaped contracts or customer-identity semantics.
## 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 |
|---|---|---|---|---|---|---|
| Customer review workspace page | yes | Native Filament page reusing existing review/detail resources | reporting, evidence viewers, download actions, disclosure hierarchy | page state, tenant prefilter state, disclosure state | no | Adds one canonical customer-safe workspace path without creating a separate portal shell |
## 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 |
|---|---|---|---|---|---|---|---|
| Customer review workspace page | Primary Decision Surface | A readonly-capable tenant actor decides whether the latest review is consumable as-is or needs a follow-up conversation with the workspace operator team | latest customer-safe review outcome, key finding counts, accepted-risk summary, published date, and pack availability | latest review detail, review-pack detail/download, and evidence detail only when explicitly opened and capability-allowed | Primary because it becomes the first truthful customer-safe entry point instead of forcing users to reconstruct the answer from internal reporting resources | Keeps review consumption inside one calm workspace path and uses existing detail routes only when the user asks for proof | Replaces cross-surface searching with one page that summarizes what matters first and delays diagnostics until requested |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Customer review workspace page | customer-read-only, operator-MSP | latest published review state, executive outcome, key findings, accepted risks, published or generated time, and review-pack availability | deeper evidence freshness, full section detail, and secondary related links only after explicit open | raw JSON, unrestricted audit metadata, provider payloads, and platform-only debug semantics remain hidden and are never part of the default page | `Open latest review` | raw/support detail is excluded from the page; evidence and audit drilldown remain capability-gated on reused detail routes | the workspace page states one summary truth per tenant and relies on existing review/pack/evidence detail pages for proof instead of repeating the same explanation in parallel blocks |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Customer review workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the latest review for one tenant or download the latest available review pack | full-row navigation to the latest customer-safe tenant review | required | one safe inline download shortcut when a pack is already available; any deeper proof remains inside the opened detail view | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` with secondary reuse of tenant-scoped review-pack and evidence detail routes | workspace context, tenant filter, and latest published-review status | Customer review | whether a tenant has a current customer-safe review, what it says at a high level, and whether a pack is available | 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer review workspace page | Readonly tenant actor inside the existing admin plane | Consume the latest customer-safe review and decide whether a follow-up conversation is needed | Workspace read-only review overview | What is the latest reviewed state for my entitled tenant, what risks are already accepted, and what can I safely open or download? | tenant identity, latest published review state, outcome summary, key findings summary, accepted-risk summary, latest review time, and review-pack availability | secondary proof routes, evidence freshness detail, and audit-aware artifact provenance only after explicit drilldown | review lifecycle, governance outcome, evidence freshness, pack availability | none | Open latest review, Download review pack | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no. V1 should reuse existing review, evidence, redaction, and artifact-truth seams directly.
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Review artifacts already exist, but there is still no product-honest customer-safe way to consume them as a coherent workspace review experience.
- **Existing structure is insufficient because**: Existing review register and tenant-scoped resource views are good proof surfaces, but they are not a calm customer-default path and they spread the answer across several internal pages.
- **Narrowest correct implementation**: Add one read-only workspace page over existing tenant review, review-pack, evidence, redaction, and RBAC truth, and defer any customer-specific identity, publishing workflow, or portal shell.
- **Ownership cost**: One page, one bounded workspace query/projection, focused authorization tests, and a small browser smoke slice.
- **Alternative intentionally rejected**: A separate customer portal or customer-specific persistence model was rejected because the repo already has the required review artifacts and readonly-capable roles in the current admin plane.
- **Release truth**: current-release blocker, not future-release preparation
### 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**: Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: Focused feature tests prove workspace and tenant isolation, capability gating, default-visible disclosure, deep-link rules, and no-write behavior. One explicit browser smoke test proves the calm read-only surface, the absence of admin actions, and the expected open/download flow under realistic UI conditions.
- **New or expanded test families**: one bounded `Reviews/CustomerReviewWorkspace` feature family and one explicit browser smoke test for the same surface
- **Fixture / helper cost impact**: moderate but contained; reuse existing workspace membership, tenant membership, tenant review, review pack, evidence snapshot, finding, finding exception, and audit fixtures instead of adding new heavy provider or queue defaults
- **Heavy-family visibility / justification**: exactly one browser smoke is justified because the core value of this slice is a customer-safe disclosure experience; no broader browser or heavy-governance family is introduced
- **Special surface test profile**: standard-native-filament, shared-detail-family
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for routing, authorization, empty states, and deep-link rules; a single browser smoke should verify that the default-visible page stays calm and read-only
- **Reviewer handoff**: Reviewers must confirm that readonly actors can use the surface, unauthorized tenant filters or deep links do not leak tenant presence, raw diagnostics never appear by default, and no create, publish, regenerate, refresh, expire, triage, or remediation action becomes visible on the customer workspace page.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
## Scope Boundaries
### In Scope
- one canonical workspace-level read-only customer review surface in the existing admin plane
- latest published customer-safe review state per entitled tenant
- key findings and accepted-risk summaries derived from existing review and finding-exception truth
- opening existing tenant review detail pages from the workspace surface
- opening or downloading existing review-pack artifacts when already available and permitted
- optional drilldown into existing evidence detail only through explicit, capability-gated navigation
- redaction-safe disclosure using existing redaction semantics and notes
- auditability for explicit artifact access and download actions using the current audit infrastructure
### Non-Goals
- any new customer portal shell, customer account model, or external identity plane
- authoring, publishing, archiving, regenerating, refreshing, expiring, or deleting review artifacts
- exception editing, risk acceptance changes, or findings remediation flows
- raw JSON, provider payloads, unrestricted audit metadata, support diagnostics, or platform-debug semantics in the default path
- new review persistence, new publication state families, or new workspace-owned review entities
- support desk flow, billing, contracts, or broader customer lifecycle workflows
- cross-tenant decision inboxes, promotion workflows, or broad MSP workboards
## Assumptions
- The customer-safe default path should use the latest published review for each entitled tenant. Draft, failed, or otherwise internal-only review states stay off the default workspace page.
- Existing readonly-capable tenant roles are sufficient for v1 and do not require a new customer-only role family.
- Accepted-risk disclosure can be derived from existing finding and finding-exception truth without creating a parallel customer-review reason model.
- Existing redaction notes and review-pack download controls are sufficient for v1 customer-safe disclosure.
## Risks
- Some tenants may have strong internal review artifacts but no published customer-safe review yet, which can make the new surface appear empty unless absence states are explained clearly.
- Existing review detail pages may still contain operator-oriented sections that need tighter entry rules or more careful disclosure when reached from the new workspace path.
- Partial capability combinations could produce uneven disclosure if the implementation does not clearly separate page-level access from optional deep-link sections.
- A later implementation could try to fold review-pack generation or broader customer portal scope into this slice; that must be rejected as out-of-scope growth.
## Follow-up Candidates
- customer-facing portal or external identity work only if the current admin-plane read-only model becomes insufficient
- support diagnostic pack linkage from customer review artifacts once the support packaging flow needs direct customer-facing entry
- explicit review publication workflow maturity if published versus ready review semantics need a broader operator workflow
- broader customer lifecycle and commercial packaging once review consumption no longer fits inside the existing admin plane
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Open the latest customer-safe review (Priority: P1)
As a readonly-capable tenant actor, I want one workspace page that shows the latest customer-safe review state for my entitled tenant so I can understand the current posture without navigating several internal reporting screens.
**Why this priority**: This is the core product gap. If the user still needs to reconstruct the latest review state manually, the slice fails its purpose.
**Independent Test**: Sign in as a readonly-capable tenant actor with one or more entitled tenants, open the customer review workspace, and verify that each visible tenant row shows only the latest published customer-safe review summary.
**Acceptance Scenarios**:
1. **Given** the actor is entitled to one or more tenants with published reviews, **When** they open the workspace review page, **Then** they see one latest customer-safe review entry per entitled tenant and no draft-only review rows.
2. **Given** the actor launches the page from a tenant-scoped review or evidence route, **When** the workspace page opens, **Then** that tenant is prefiltered and its latest published review is highlighted first.
3. **Given** the actor has no entitled tenants with published reviews, **When** they open the page, **Then** they see a truthful absence state that does not reveal hidden drafts or inaccessible tenants.
---
### User Story 2 - Understand findings and accepted risks without admin controls (Priority: P1)
As a readonly-capable tenant actor, I want the latest review summary to explain key findings and accepted risks in calm language so I can understand what matters without seeing remediation or operator-only actions.
**Why this priority**: Customer-safe review consumption is not useful if the page still looks like an operator console or hides the meaning behind the review outcome.
**Independent Test**: Open the workspace page and the latest review detail for a tenant that has findings and accepted risks, then verify that the user can understand the current outcome without seeing create, publish, regenerate, expire, triage, or remediation controls.
**Acceptance Scenarios**:
1. **Given** a tenant has a published review with findings and accepted risks, **When** the actor opens the workspace page, **Then** the row or summary exposes the high-level counts and meaning of those items without requiring a drilldown first.
2. **Given** the actor opens the latest review detail from the workspace page, **When** the detail loads, **Then** the review remains read-only and does not expose admin or remediation actions the actor cannot use.
3. **Given** raw diagnostics or unrestricted audit metadata exist behind the review, **When** the actor uses the customer workspace flow, **Then** those details remain hidden from the default-visible path.
---
### User Story 3 - Consume the current review pack safely (Priority: P2)
As a readonly-capable tenant actor, I want to open or download the current review pack when it already exists so I can consume the packaged review output without triggering generation or seeing unsafe disclosure.
**Why this priority**: Review consumption is incomplete if the user can read the summary but cannot reach the packaged artifact that already represents the customer-safe deliverable.
**Independent Test**: From the workspace page, open a tenant that has a current review pack and verify that download works through existing access and redaction rules, while tenants without an available pack show a calm unavailable state.
**Acceptance Scenarios**:
1. **Given** a tenant has a current review pack and the actor has `REVIEW_PACK_VIEW`, **When** they choose the pack action, **Then** they can open or download the existing artifact without any generate or regenerate prompt.
2. **Given** a tenant has no current downloadable review pack, **When** the actor views the workspace page, **Then** the page shows that the pack is unavailable and does not offer a generation action.
3. **Given** a review pack includes redaction-safe content only, **When** the actor downloads it, **Then** the artifact and surrounding disclosure continue to honor existing redaction semantics.
### Edge Cases
- What happens when a tenant has a ready review but nothing published yet? The workspace page shows `No published review available yet` rather than exposing internal-only lifecycle states.
- What happens when a query parameter or remembered filter points at a tenant outside the actor's scope? The page resolves as not found for explicit tenant targeting and silently omits inaccessible tenants from broad workspace listings.
- What happens when the actor can view reviews but not review packs or evidence? The page remains usable, but pack and evidence actions are absent rather than replaced with leaking hints.
- What happens when a review pack exists but is expired or otherwise unavailable for consumption? The page shows an unavailable state and does not offer regeneration or admin recovery actions.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce Graph calls, write/change behavior, or long-running work. It does change runtime behavior, authorization posture, disclosure rules, and audit expectations for a new read-only customer-facing surface in the existing admin plane.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature must stay derived. It must not add new persistence, new customer-state families, new publication semantics, or a parallel presenter framework.
**Constitution alignment (XCUT-001):** The feature must extend existing review, evidence, review-pack, and artifact-truth paths rather than creating a local customer-review semantic layer.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The default path must remain customer-readable, decision-first, and free from raw diagnostics, with deeper proof only on demand.
**Constitution alignment (TEST-GOV-001):** The implementation must add focused feature tests plus one explicit browser smoke test; no hidden heavy family may spread from this slice.
**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries; page and deep-link authorization must use canonical capability checks rather than raw role checks.
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The new surface must remain a native Filament read-only reporting page with one dominant inspect action, one optional safe download shortcut, and no destructive or remediation controls.
### Functional Requirements
- **FR-001**: The system MUST provide one canonical read-only customer review workspace in the existing `/admin` plane for the current workspace.
- **FR-002**: The system MUST list only entitled tenants and MUST derive each visible row or card from existing tenant-owned review, evidence, review-pack, and findings truth.
- **FR-003**: The default-visible page MUST show the latest published customer-safe review state per entitled tenant and MUST NOT expose draft, failed, or other internal-only review states as the primary customer path.
- **FR-004**: The page MUST show, for each visible tenant, the current review outcome, latest review time, key findings summary, accepted-risk summary, and review-pack availability in calm, read-only language.
- **FR-005**: The page MUST offer a primary inspect action that opens the existing tenant-scoped review detail for the latest customer-safe review.
- **FR-006**: The page MUST allow entitled actors to open or download an existing review pack only through current `REVIEW_PACK_VIEW` access and existing redaction-safe artifact rules.
- **FR-007**: The page MUST NOT expose review generation, publication, regeneration, refresh, expire, triage, risk acceptance, remediation, or admin-setting actions.
- **FR-008**: The page and its deep links MUST enforce workspace and tenant isolation such that non-members or out-of-scope tenant targets resolve as not found.
- **FR-009**: Within an established workspace and tenant scope, optional sections and actions MUST be gated through the canonical capability registry, including `TENANT_VIEW`, `TENANT_REVIEW_VIEW`, `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` where relevant.
- **FR-010**: The feature MUST reuse existing artifact truth and publication-readiness semantics from current review, review-pack, and evidence surfaces and MUST NOT create a separate customer-review truth model.
- **FR-011**: Raw operator diagnostics, raw JSON or provider payloads, unrestricted audit metadata, and platform-only debug semantics MUST remain out of the default-visible customer workspace path.
- **FR-012**: Explicit artifact opens or downloads exposed through this surface MUST remain auditable using the current audit infrastructure without introducing a new audit store.
- **FR-013**: When entered from a tenant-scoped review, review-pack, evidence, or related tenant context, the workspace page MUST preserve that tenant context as a safe prefilter.
- **FR-014**: When no published customer-safe review or downloadable review pack exists, the page MUST show a truthful unavailable state instead of hinting at hidden drafts, operator-only artifacts, or unavailable generation paths.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace | new `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only when a tenant or status prefilter is active | clickable row or card opening the latest tenant review | `Open latest review`, `Download review pack` when already available and permitted | none | `Clear filters` when filtered; otherwise an explanatory no-data state is allowed because the page is strictly read-only and intentionally has no create CTA | `N/A` - detail actions remain on reused tenant-scoped review and review-pack resources | `N/A` | yes - explicit artifact access and download events only | No destructive actions. No More menu required unless the implementation cannot keep open/download as the only visible actions. |
### Key Entities *(include if feature involves data)*
- **Customer Review Workspace Entry**: A derived workspace-scoped summary for one entitled tenant that combines the latest published tenant review, high-level findings and accepted-risk summaries, and current review-pack availability without becoming a persisted entity.
- **TenantReview**: The existing tenant-owned review artifact that anchors the latest customer-safe review state, lifecycle, executive summary, and deep-link target.
- **ReviewPack**: The existing tenant-owned downloadable artifact that packages review consumption and already carries redaction-aware access rules.
- **EvidenceSnapshot**: The existing tenant-owned supporting artifact that proves freshness and completeness when the actor explicitly drills deeper than the customer-safe default path.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An entitled readonly-capable actor can reach the latest customer-safe review state for an entitled tenant in two steps or fewer from workspace context.
- **SC-002**: In 100% of validated readonly scenarios, the default-visible customer workspace path shows no admin, remediation, regeneration, or raw-diagnostics actions.
- **SC-003**: In 100% of validated unauthorized workspace or tenant access scenarios, the feature does not reveal another tenant's presence, review existence, or artifact availability.
- **SC-004**: For tenants with a published review and an available review pack, entitled users can open the latest review or download the pack on their first attempt without operator assistance.
- **SC-005**: For tenants without a published customer-safe review or current pack, the surface explains the absence truthfully without exposing draft-only or operator-only state.

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