Merge 248-private-ai-policy-foundation into dev #288

Merged
ahmido merged 6 commits from 248-private-ai-policy-foundation into dev 2026-04-27 21:18:38 +00:00
44 changed files with 4357 additions and 3836 deletions
Showing only changes of commit 6383f205a1 - Show all commits

View File

@ -7,6 +7,8 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
@ -22,6 +24,7 @@
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -54,6 +57,7 @@ class WorkspaceSettings extends Page
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}> * @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/ */
private const SETTING_FIELDS = [ 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_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
@ -248,6 +252,27 @@ public function content(Schema $schema): Schema
->disabled(fn (): bool => ! $this->currentUserCanManage()) ->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()), ->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') Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.')) ->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([ ->schema([
@ -793,6 +818,57 @@ private function reviewPackGenerationReasonHelperText(): string
); );
} }
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 private function entitlementReasonHelperText(string $valueField, string $key): string
{ {
$decision = $this->entitlementDecision($key); $decision = $this->entitlementDecision($key);

View File

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

View File

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

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

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

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

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

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

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

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

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

@ -101,6 +101,7 @@ enum AuditActionId: string
case SupportDiagnosticsOpened = 'support_diagnostics.opened'; case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created'; case SupportRequestCreated = 'support_request.created';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused'; case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated'; case OperationalControlUpdated = 'operational_control.updated';
case OperationalControlResumed = 'operational_control.resumed'; case OperationalControlResumed = 'operational_control.resumed';
@ -243,6 +244,7 @@ private static function labels(): array
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created', self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated', self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed', self::OperationalControlResumed->value => 'Operational control resumed',
@ -330,6 +332,7 @@ private static function summaries(): array
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created', self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated', self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed', self::OperationalControlResumed->value => 'Operational control resumed',

View File

@ -17,6 +17,13 @@ final class OperationalControlCatalog
'operation_types' => ['restore.execute'], 'operation_types' => ['restore.execute'],
'affected_surfaces' => ['tenant.restore_runs.create'], '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,6 +6,7 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Ai\AiDataClassification;
use App\Support\Governance\PlatformVocabularyGlossary; use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks; use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
@ -147,6 +148,43 @@ public function knowledgeSource(): array
return $this->catalog->knowledgeSource(); 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 * @param array<string, mixed>|null $verificationReport
*/ */

View File

@ -4,6 +4,7 @@
namespace App\Support\Settings; namespace App\Support\Settings;
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding; use App\Models\Finding;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
@ -18,6 +19,15 @@ public function __construct()
{ {
$this->definitions = []; $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( $this->register(new SettingDefinition(
domain: 'backup', domain: 'backup',
key: 'retention_keep_last_default', key: 'retention_keep_last_default',

View File

@ -19,6 +19,7 @@
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder; use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
@ -133,6 +134,39 @@ 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 * @param list<array<string, mixed>> $sections
* @return array<string, mixed> * @return array<string, mixed>

View File

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

@ -2,14 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun; use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationalControlActivation; use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -130,4 +133,50 @@ function seedRestoreAuthorizationContext(): array
]) ])
->call('create') ->call('create')
->assertNotified('Restore execution paused'); ->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

@ -0,0 +1,66 @@
<?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,3 +79,76 @@
->and(data_get($audit?->metadata, 'before_value'))->toBe(48) ->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
->and(data_get($audit?->metadata, 'after_value'))->toBe(30); ->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,6 +44,7 @@ function workspaceManagerUser(): array
$component = Livewire::actingAs($user) $component = Livewire::actingAs($user)
->test(WorkspaceSettings::class) ->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', null)
->assertSet('data.backup_retention_keep_last_default', null) ->assertSet('data.backup_retention_keep_last_default', null)
->assertSet('data.backup_retention_min_floor', null) ->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', []) ->assertSet('data.drift_severity_mapping', [])
@ -58,6 +59,7 @@ function workspaceManagerUser(): array
->assertSet('data.findings_sla_low', null) ->assertSet('data.findings_sla_low', null)
->assertSet('data.operations_operation_run_retention_days', null) ->assertSet('data.operations_operation_run_retention_days', null)
->assertSet('data.operations_stuck_run_threshold_minutes', 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_keep_last_default', 55)
->set('data.backup_retention_min_floor', 12) ->set('data.backup_retention_min_floor', 12)
->set('data.drift_severity_mapping', ['drift' => 'critical']) ->set('data.drift_severity_mapping', ['drift' => 'critical'])
@ -74,6 +76,7 @@ function workspaceManagerUser(): array
->set('data.operations_stuck_run_threshold_minutes', 60) ->set('data.operations_stuck_run_threshold_minutes', 60)
->callAction('save') ->callAction('save')
->assertHasNoErrors() ->assertHasNoErrors()
->assertSet('data.ai_policy_mode', 'private_only')
->assertSet('data.backup_retention_keep_last_default', 55) ->assertSet('data.backup_retention_keep_last_default', 55)
->assertSet('data.backup_retention_min_floor', 12) ->assertSet('data.backup_retention_min_floor', 12)
->assertSet('data.baseline_severity_missing_policy', 'critical') ->assertSet('data.baseline_severity_missing_policy', 'critical')
@ -97,6 +100,9 @@ function workspaceManagerUser(): array
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
->toBe(55); ->toBe(55);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('private_only');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor')) expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
->toBe(12); ->toBe(12);
@ -142,6 +148,18 @@ function workspaceManagerUser(): array
->where('key', 'retention_keep_last_default') ->where('key', 'retention_keep_last_default')
->exists())->toBeFalse(); ->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 $component
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content') ->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
->callMountedFormComponentAction() ->callMountedFormComponentAction()

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Settings\WorkspaceSettings; use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire; use Livewire\Livewire;
@ -12,6 +13,14 @@
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::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()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user) $this->actingAs($user)

View File

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

View File

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

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

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

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

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

@ -0,0 +1,273 @@
# 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,6 +104,52 @@ ### 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 & Information Architecture
### UI/UX constitution governs operator surfaces ### UI/UX constitution governs operator surfaces

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
# 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,24 +1,210 @@
# Implementation Plan: Cross-tenant Compare and Promotion # Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
**Date**: 2026-01-07 **Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
**Spec**: `specs/043-cross-tenant-compare-and-promotion/spec.md` **Input**: Feature specification from [spec.md](spec.md)
## Summary ## Summary
Introduce read-only cross-tenant comparison views; optionally add promotion with strong safety gates. 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.
## Dependencies 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.
- Inventory core + UI (Specs 040041) ## Technical Context
- Strong authorization model for multi-tenant access
## Deliverables **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
- Tenant selection + comparison view ## UI / Surface Guardrail Plan
- Safe diff output and export
- (Optional) gated promotion workflow
## Risks - **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
- Data leakage across tenants ## Shared Pattern & System Fit
- Over-scoping promotion beyond safe MVP
- **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.

View File

@ -1,59 +1,293 @@
# Feature Specification: Cross-tenant Compare and Promotion # Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
**Feature Branch**: `feat/043-cross-tenant-compare-and-promotion` **Feature Branch**: `043-cross-tenant-compare-and-promotion`
**Created**: 2026-01-07 **Created**: 2026-01-07
**Status**: Draft **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.
## Purpose ## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
Enable safe cross-tenant comparison of inventory and, optionally, controlled promotion workflows. - **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
Comparison is read-only by default. Any write/promotion behavior must be explicitly gated, audited, and separately authorized. ## Spec Scope Fields *(mandatory)*
## User Scenarios & Testing - **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
### Scenario 1: Compare two tenants (read-only) For canonical-view specs, the spec MUST define:
- 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
### Scenario 2: Compare with a stable reference - **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.
- Given a reference selection scope - **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.
- When the operator runs comparison
- Then results are stable and reproducible for that scope
### Scenario 3: Promotion is explicitly gated (optional) ## 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`)*
- Given promotion is enabled by policy
- When the operator initiates promotion
- Then the system requires explicit confirmation and records an audit event
## Functional Requirements - **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.
- FR1: Support selecting two tenants within authorized scope. ## 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`)*
- 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
## Non-Functional Requirements - **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
- NFR1: Enforce tenant isolation and least privilege across tenant selection and data access. ## 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`)*
- NFR2: Comparison must not expose secrets or unsafe payload fields.
- **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.
## Success Criteria ## Success Criteria
- SC1: Operators can identify which tenant differs for a given policy type in under 2 minutes. - **SC1**: An authorized operator can produce a cross-tenant compare preview from one canonical page without switching across multiple tenant detail surfaces.
- SC2: Read-only comparisons are reproducible when run again with the same scope. - **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.
## Out of Scope - **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.
- Bulk remediation without preview/confirmation.
## Related Specs ## Related Specs
- Program: `specs/039-inventory-program/spec.md` - Program: `specs/039-inventory-program/spec.md`
- Core: `specs/040-inventory-core/spec.md` - Core: `specs/040-inventory-core/spec.md`
- UI: `specs/041-inventory-ui/spec.md`
- Drift: `specs/044-drift-mvp/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,7 +1,190 @@
# Tasks: Cross-tenant Compare and Promotion ---
- [ ] T001 Define authorized tenant selection rules description: "Task list for Cross-Tenant Compare Preview and Promotion Preflight"
- [ ] 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 # 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.

View File

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

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

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

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

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

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

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

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