Compare commits

..

1 Commits

Author SHA1 Message Date
55338a88c6 merge: platform-dev into dev (#311)
Some checks failed
Main Confidence / confidence (push) Failing after 59s
PR Fast Feedback / fast-feedback (pull_request) Failing after 46s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- sync platform-dev back into dev with the latest integrated feature and spec work
- include the customer review workspace productization flow and its related review, review-pack, evidence, audit, and test updates
- carry forward the recent governance and roadmap/spec updates already merged on platform-dev

## Included highlights
- customer review workspace productization and customer-safe released-review drilldown
- governance decision convergence work
- cross-tenant compare and promotion work
- external support desk handoff work
- product, roadmap, permissions, and spec artifact updates

## Validation context
- platform-dev currently contains the already-validated feature work from the merged branch PRs
- latest customer review workspace batch included focused Pest suites, one bounded browser smoke, and Pint

## Notes
- this is an integration PR from platform-dev into dev
- no separate provider-registration or asset-strategy expansion is introduced by the customer review workspace slice

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #311
2026-04-30 18:33:56 +00:00
104 changed files with 562 additions and 9364 deletions

View File

@ -266,10 +266,6 @@ ## Active Technologies
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams (259-compliance-evidence-mapping)
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `evidence_snapshots`, `evidence_snapshot_items`, `findings`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence table planned (259-compliance-evidence-mapping)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure (260-governance-service-packaging)
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `review_packs`, `evidence_snapshots`, `evidence_snapshot_items`, `stored_reports`, `findings`, `finding_exceptions`, `finding_exception_decisions`, memberships, and `audit_logs`; no new persistence planned (260-governance-service-packaging)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -304,9 +300,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 260-governance-service-packaging: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure
- 259-compliance-evidence-mapping: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -5,4 +5,4 @@
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
comit all changes, push to remote, and create a pull request against platform-dev with gitea mcp
comit all changes, push to remote, and create a pull request against dev with gitea mcp

View File

@ -50,7 +50,7 @@ public function handle(): int
$changedVersions = 0;
$changedPolicies = 0;
$providerMissingPolicies = 0;
$ignoredPolicies = 0;
foreach ($candidates as $policy) {
$latestVersion = $policy->versions()->latest('version_number')->first();
@ -86,15 +86,14 @@ public function handle(): int
->first();
if ($existingTarget) {
$policy->forceFill(['missing_from_provider_at' => now()])->save();
$providerMissingPolicies++;
$policy->forceFill(['ignored_at' => now()])->save();
$ignoredPolicies++;
continue;
}
$policy->forceFill([
'policy_type' => 'windowsEnrollmentStatusPage',
'missing_from_provider_at' => null,
])->save();
$changedPolicies++;
@ -107,7 +106,7 @@ public function handle(): int
$this->info('Done.');
$this->info('PolicyVersions changed: '.$changedVersions);
$this->info('Policies changed: '.$changedPolicies);
$this->info('Policies marked provider-missing: '.$providerMissingPolicies);
$this->info('Policies ignored: '.$ignoredPolicies);
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
return Command::SUCCESS;

View File

@ -7,21 +7,20 @@
use App\Filament\Resources\TenantReviewResource;
use App\Models\EvidenceSnapshot;
use App\Models\FindingException;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ReviewPackStatus;
use App\Support\TenantReviewCompletenessState;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -73,10 +72,10 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The dedicated open link column opens the latest published review detail instead of an inline canonical detail panel.');
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
}
public static function getNavigationGroup(): string
@ -153,41 +152,39 @@ public function table(Table $table): Table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(null)
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable(),
TextColumn::make('package_availability')
->label(__('localization.review.governance_package'))
->width('9rem')
->extraHeaderAttributes(['class' => 'whitespace-normal'])
->badge()
->getStateUsing(fn (Tenant $record): string => $this->governancePackageAvailabilityLabel($record))
->color(fn (Tenant $record): string => $this->governancePackageAvailabilityColor($record))
->tooltip(fn (Tenant $record): string => $this->governancePackageAvailability($record)['description']),
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review')
->label(__('localization.review.status'))
->width('9rem')
->label(__('localization.review.latest_review'))
->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)),
TextColumn::make('evidence_proof_state')
->label(__('localization.review.evidence_status'))
->width('8rem')
->badge()
->getStateUsing(fn (Tenant $record): string => $this->evidenceStatusLabel($record))
->color(fn (Tenant $record): string => $this->evidenceStatusColor($record)),
TextColumn::make('recommended_next_action')
->label(__('localization.review.next_step'))
->width('10rem')
->extraHeaderAttributes(['class' => 'whitespace-normal'])
->getStateUsing(fn (Tenant $record): string => $this->controlRecommendedNextAction($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(),
TextColumn::make('finding_summary')
->label(__('localization.review.key_findings'))
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label(__('localization.review.accepted_risks'))
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('evidence_proof_state')
->label(__('localization.review.evidence_proof'))
->getStateUsing(fn (Tenant $record): string => $this->evidenceProofAvailability($record))
->wrap(),
TextColumn::make('published_at')
->label(__('localization.review.published'))
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label(__('localization.review.review_pack'))
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record))
->wrap(),
TextColumn::make('open_review')
->label(__('localization.review.open'))
->width('8rem')
->getStateUsing(fn (): string => __('localization.review.open_review'))
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->color('primary'),
])
->filters([
SelectFilter::make('tenant_id')
@ -203,12 +200,24 @@ public function table(Table $table): Table
})
->searchable(),
])
->actions([])
->actions([
Action::make('open_latest_review')
->label(__('localization.review.open_latest_review'))
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack')
->label(__('localization.review.download_review_pack'))
->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
])
->bulkActions([])
->emptyStateHeading(__('localization.review.no_released_customer_reviews'))
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? __('localization.review.clear_filters_description')
: __('localization.review.no_released_customer_reviews_description'))
: __('localization.review.adjust_filters_description'))
->emptyStateActions([
Action::make('clear_filters_empty')
->label(__('localization.review.clear_filters'))
@ -279,8 +288,6 @@ private function auditWorkspaceOpen(): void
'source_surface' => self::SOURCE_SURFACE,
'tenant_filter_id' => $this->currentTenantFilterId(),
'entitled_tenant_count' => count($this->authorizedTenants()),
'interpretation_version' => $this->currentTenantFilterInterpretationVersion(),
'interpretation_versions' => $this->visibleInterpretationVersions(),
],
],
actor: $user,
@ -391,19 +398,47 @@ private function latestReviewUrl(Tenant $tenant): ?string
return null;
}
$query = array_filter(
return $this->appendQuery(
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
array_replace(
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
[
'source_surface' => self::SOURCE_SURFACE,
'tenant_filter_id' => $this->currentTenantFilterId(),
],
$this->navigationContext()?->toQuery() ?? [],
),
static fn (mixed $value): bool => $value !== null && $value !== '',
);
}
return $this->appendQuery(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'), $query);
private function latestReviewPack(Tenant $tenant): ?ReviewPack
{
$review = $this->latestPublishedReview($tenant);
$pack = $review?->currentExportReviewPack;
return $pack instanceof ReviewPack ? $pack : null;
}
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
{
$user = auth()->user();
$pack = $this->latestReviewPack($tenant);
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => self::SOURCE_SURFACE,
]);
}
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
@ -436,34 +471,12 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
private function latestReviewStateLabel(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review');
}
return $this->workspaceReviewNeedsAttention($tenant)
? __('localization.review.review_requires_attention')
: __('localization.review.ready_for_release');
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
}
private function latestReviewStateColor(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'gray';
}
$packageState = $this->governancePackageAvailability($tenant)['state'];
if (! $this->workspaceReviewNeedsAttention($tenant)) {
return 'success';
}
return in_array($packageState, ['blocked', 'expired'], true)
? 'danger'
: 'warning';
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
}
private function latestReviewStateIcon(Tenant $tenant): ?string
@ -501,342 +514,6 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
}
private function controlReadinessLabel(Tenant $tenant): string
{
$control = $this->primaryControlSummary($tenant);
if ($control === null) {
return __('localization.review.control_readiness_unmapped');
}
$label = $control['readiness_label'] ?? null;
return is_string($label) && trim($label) !== ''
? $label
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
}
/**
* @return array<string, mixed>
*/
private function governancePackageSummary(Tenant $tenant): array
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return [];
}
$summary = is_array($review->summary) ? $review->summary : [];
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
return $package;
}
/**
* @return array{state:string,label:string,description:string}
*/
private function governancePackageAvailability(Tenant $tenant): array
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return [
'state' => 'unavailable',
'label' => __('localization.review.governance_package_unavailable'),
'description' => __('localization.review.no_published_review_available'),
];
}
$pack = $review->currentExportReviewPack;
$user = auth()->user();
$limitations = is_array($review->controlInterpretation()['limitations'] ?? null) ? $review->controlInterpretation()['limitations'] : [];
$isPartialReview = in_array((string) $review->completeness_state, [
TenantReviewCompletenessState::Partial->value,
TenantReviewCompletenessState::Stale->value,
], true) || $limitations !== [];
if (! $pack instanceof ReviewPack) {
return [
'state' => 'unavailable',
'label' => __('localization.review.governance_package_unavailable'),
'description' => __('localization.review.governance_package_unavailable_description'),
];
}
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return [
'state' => 'blocked',
'label' => __('localization.review.governance_package_blocked'),
'description' => __('localization.review.governance_package_blocked_description'),
];
}
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
return [
'state' => 'expired',
'label' => __('localization.review.governance_package_expired'),
'description' => __('localization.review.governance_package_expired_description'),
];
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return [
'state' => 'unavailable',
'label' => __('localization.review.governance_package_unavailable'),
'description' => __('localization.review.governance_package_not_ready_description'),
];
}
if ($isPartialReview) {
return [
'state' => 'partial',
'label' => __('localization.review.governance_package_partial'),
'description' => __('localization.review.governance_package_partial_description'),
];
}
return [
'state' => 'available',
'label' => __('localization.review.governance_package_available'),
'description' => __('localization.review.governance_package_available_description'),
];
}
private function governancePackageAvailabilityLabel(Tenant $tenant): string
{
return match ($this->governancePackageAvailability($tenant)['state']) {
'available' => __('localization.review.available'),
'partial' => __('localization.review.partial'),
'blocked' => __('localization.review.blocked'),
'expired' => __('localization.review.expired'),
default => __('localization.review.unavailable'),
};
}
private function governancePackageAvailabilityColor(Tenant $tenant): string
{
return match ($this->governancePackageAvailability($tenant)['state']) {
'available' => 'success',
'partial' => 'warning',
'blocked', 'expired' => 'danger',
default => 'gray',
};
}
private function governancePackageTeaser(Tenant $tenant): string
{
$package = $this->governancePackageSummary($tenant);
$executiveSummary = $package['executive_summary'] ?? null;
if (is_string($executiveSummary) && trim($executiveSummary) !== '') {
return $executiveSummary;
}
return $this->governancePackageAvailability($tenant)['description'];
}
private function controlReadinessColor(Tenant $tenant): string
{
return match ((string) ($this->primaryControlSummary($tenant)['readiness_bucket'] ?? 'unmapped')) {
'follow_up_required' => 'warning',
'review_recommended' => 'info',
'evidence_on_record' => 'success',
default => 'gray',
};
}
private function controlReadinessDescription(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$controls = $review->controlInterpretationControls();
if ($controls === []) {
return __('localization.review.control_readiness_unmapped_description');
}
$summary = collect($controls)
->take(2)
->map(function (array $control): string {
$name = is_string($control['control_name'] ?? null) ? $control['control_name'] : __('localization.review.control');
$label = is_string($control['readiness_label'] ?? null)
? $control['readiness_label']
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
return $name.': '.$label;
})
->implode(' · ');
$remaining = count($controls) - 2;
if ($remaining > 0) {
$summary .= ' · '.__('localization.review.additional_controls', ['count' => $remaining]);
}
$limitations = $this->controlLimitationSummary($review);
return trim($summary.($limitations !== null ? ' '.$limitations : ''));
}
private function controlEvidenceBasisSummary(Tenant $tenant): string
{
$control = $this->primaryControlSummary($tenant);
if ($control === null) {
return __('localization.review.control_evidence_unmapped');
}
$summary = $control['evidence_basis_summary'] ?? null;
return is_string($summary) && trim($summary) !== ''
? $summary
: __('localization.review.control_evidence_unavailable');
}
private function controlRecommendedNextAction(Tenant $tenant): string
{
if ($this->primaryControlSummary($tenant) === null) {
return __('localization.review.workspace_next_step_control_mapping');
}
if ($this->evidenceStatusState($tenant) !== 'available') {
return __('localization.review.workspace_next_step_evidence_review');
}
return match ($this->governancePackageAvailability($tenant)['state']) {
'available', 'partial' => __('localization.review.workspace_next_step_package_review'),
default => __('localization.review.workspace_next_step_review_open'),
};
}
private function workspaceReviewNeedsAttention(Tenant $tenant): bool
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return true;
}
if ($this->primaryControlSummary($tenant) === null) {
return true;
}
if ($this->evidenceStatusState($tenant) !== 'available') {
return true;
}
return $this->governancePackageAvailability($tenant)['state'] !== 'available';
}
private function evidenceStatusState(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'pending';
}
$snapshot = $review->evidenceSnapshot;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot) {
return 'pending';
}
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return 'restricted';
}
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
return 'expired';
}
return 'available';
}
private function evidenceStatusLabelForState(string $state): string
{
return match ($state) {
'available' => __('localization.review.available'),
'restricted' => __('localization.review.restricted'),
'expired' => __('localization.review.expired'),
default => __('localization.review.pending'),
};
}
private function evidenceStatusColorForState(string $state): string
{
return match ($state) {
'available' => 'success',
'restricted', 'expired' => 'danger',
default => 'gray',
};
}
private function controlRecommendedNextActionDescription(Tenant $tenant): string
{
$control = $this->primaryControlSummary($tenant);
if ($control === null) {
return __('localization.review.control_recommendation_unmapped');
}
$action = $control['recommended_next_action'] ?? null;
return is_string($action) && trim($action) !== ''
? $action
: __('localization.review.no_action_needed');
}
/**
* @return array<string, mixed>|null
*/
private function primaryControlSummary(Tenant $tenant): ?array
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return null;
}
$controls = collect($review->controlInterpretationControls());
return $controls
->sortBy(static fn (array $control): int => match ((string) ($control['readiness_bucket'] ?? '')) {
'follow_up_required' => 0,
'review_recommended' => 1,
'evidence_on_record' => 2,
default => 3,
})
->first();
}
private function controlLimitationSummary(TenantReview $review): ?string
{
$counts = $review->controlInterpretationLimitationCounts();
if ($counts === []) {
return null;
}
$labels = collect($counts)
->filter(static fn (int $count): bool => $count > 0)
->keys()
->map(static fn (string $flag): string => ComplianceEvidenceMappingV1::limitationLabel($flag))
->values()
->all();
return $labels === []
? null
: __('localization.review.control_limitations_summary', ['limitations' => implode(', ', $labels)]);
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
@ -892,6 +569,34 @@ private function acceptedRiskSummary(Tenant $tenant): string
: $countSummary.' '.$accountability;
}
private function reviewPackAvailability(Tenant $tenant): string
{
if (! $this->latestPublishedReview($tenant) instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$pack = $this->latestReviewPack($tenant);
$user = auth()->user();
if (! $pack instanceof ReviewPack) {
return __('localization.review.no_current_review_pack');
}
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return __('localization.review.review_pack_access_unavailable');
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return __('localization.review.review_pack_unavailable');
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return __('localization.review.review_pack_expired');
}
return __('localization.review.review_pack_available');
}
private function evidenceProofAvailability(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
@ -918,60 +623,6 @@ private function evidenceProofAvailability(Tenant $tenant): string
return __('localization.review.evidence_proof_available');
}
private function evidenceStatusLabel(Tenant $tenant): string
{
return $this->evidenceStatusLabelForState($this->evidenceStatusState($tenant));
}
private function evidenceStatusColor(Tenant $tenant): string
{
return $this->evidenceStatusColorForState($this->evidenceStatusState($tenant));
}
/**
* @return list<string>
*/
private function visibleInterpretationVersions(): array
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return [];
}
return app(TenantReviewRegisterService::class)
->latestPublishedQuery($user, $workspace)
->get()
->map(static fn (TenantReview $review): ?string => $review->controlInterpretationVersion())
->filter()
->unique()
->values()
->all();
}
private function currentTenantFilterInterpretationVersion(): ?string
{
$tenantId = $this->currentTenantFilterId();
if ($tenantId === null) {
return null;
}
$tenant = $this->authorizedTenants()[$tenantId] ?? null;
if (! $tenant instanceof Tenant) {
return null;
}
return $tenant->tenantReviews()->published()
->latest('published_at')
->latest('generated_at')
->latest('id')
->first()
?->controlInterpretationVersion();
}
private function acceptedRiskAccountability(Tenant $tenant): ?string
{
$exception = FindingException::query()

View File

@ -262,7 +262,9 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
$packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
if (static::isCustomerWorkspaceFlow()) {
$packUrl = static::appendQuery($packUrl, static::customerWorkspaceContextQuery());
$packUrl = static::appendQuery($packUrl, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
]);
}
$entries[] = RelatedContextEntry::available(
@ -300,19 +302,6 @@ public static function isCustomerWorkspaceFlow(): bool
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
}
/**
* @return array<string, mixed>
*/
private static function customerWorkspaceContextQuery(): array
{
return array_filter([
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => request()->query('review_id'),
'interpretation_version' => request()->query('interpretation_version'),
'tenant_filter_id' => request()->query('tenant_filter_id'),
], static fn (mixed $value): bool => $value !== null && $value !== '');
}
/**
* @param array<string, mixed> $query
*/

View File

@ -133,9 +133,6 @@ private function auditCustomerWorkspaceProofOpen(): void
'metadata' => [
'evidence_snapshot_id' => (int) $record->getKey(),
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => request()->query('review_id'),
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => request()->query('interpretation_version'),
],
],
actor: $user,

View File

@ -72,16 +72,6 @@ class PolicyResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function getModelLabel(): string
{
return static::text('common.policy');
}
public static function getPluralModelLabel(): string
{
return static::text('common.policies');
}
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
@ -110,7 +100,7 @@ public static function canViewAny(): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync policies.')
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
@ -122,12 +112,12 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make($name)
->label(static::text('resource.sync_action_primary'))
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalHeading(static::text('resource.sync_modal_heading'))
->modalDescription(static::text('resource.sync_modal_description').' '.static::text('common.source_microsoft_intune'))
->modalHeading('Sync policies from Intune')
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
->action(function (Pages\ListPolicies $livewire): void {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
@ -160,7 +150,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -175,14 +165,14 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(static::text('resource.sync_permission_tooltip'))
->tooltip('You do not have permission to sync policies.')
->apply();
}
@ -195,31 +185,16 @@ public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make(static::text('resource.details_section'))
Section::make('Policy Details')
->schema([
TextEntry::make('display_name')->label(static::text('common.policy')),
TextEntry::make('policy_type')->label(static::text('common.type')),
TextEntry::make('platform')
->label(static::text('common.platform'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
TextEntry::make('visibility_state')
->label(static::text('common.visibility'))
->badge()
->state(fn (Policy $record): string => $record->visibilityState())
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
->helperText(fn (Policy $record): ?string => $record->isProviderMissing()
? static::text('resource.visibility_source_unavailable_backup_items')
: null),
TextEntry::make('external_id')->label(static::text('common.external_id')),
TextEntry::make('last_synced_at')->dateTime()->label(static::text('common.last_synced')),
TextEntry::make('created_at')->since()->label(static::text('common.created')),
TextEntry::make('display_name')->label('Policy'),
TextEntry::make('policy_type')->label('Type'),
TextEntry::make('platform'),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
TextEntry::make('created_at')->since(),
TextEntry::make('latest_snapshot_mode')
->label(static::text('common.snapshot'))
->label('Snapshot')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
@ -236,8 +211,8 @@ public static function infolist(Schema $schema): Schema
$status = $meta['original_status'] ?? null;
return sprintf(
static::text('resource.snapshot_metadata_only_helper'),
$status ?? static::text('resource.graph_error_fallback')
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
$status ?? 'an error'
);
})
->visible(fn (Policy $record) => $record->versions()->exists()),
@ -250,7 +225,7 @@ public static function infolist(Schema $schema): Schema
->activeTab(1)
->persistTabInQueryString()
->tabs([
Tab::make(static::text('resource.tab_general'))
Tab::make('General')
->id('general')
->schema([
ViewEntry::make('policy_general')
@ -261,7 +236,7 @@ public static function infolist(Schema $schema): Schema
}),
])
->visible(fn (Policy $record) => $record->versions()->exists()),
Tab::make(static::text('common.settings'))
Tab::make('Settings')
->id('settings')
->schema([
ViewEntry::make('settings')
@ -273,12 +248,12 @@ public static function infolist(Schema $schema): Schema
->visible(fn (Policy $record) => $record->versions()->exists()),
TextEntry::make('no_settings_available')
->label(static::text('common.settings'))
->state(static::text('resource.settings_empty_state'))
->helperText(static::text('resource.settings_empty_state_helper'))
->label('Settings')
->state('No policy snapshot available yet.')
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
->visible(fn (Policy $record) => ! $record->versions()->exists()),
]),
Tab::make(static::text('resource.tab_json'))
Tab::make('JSON')
->id('json')
->schema([
ViewEntry::make('snapshot_json')
@ -286,7 +261,7 @@ public static function infolist(Schema $schema): Schema
->state(fn (Policy $record) => static::latestSnapshot($record))
->columnSpanFull(),
TextEntry::make('snapshot_size')
->label(static::text('resource.payload_size'))
->label('Payload Size')
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
->formatStateUsing(function ($state) {
if ($state > 512000) {
@ -294,7 +269,7 @@ public static function infolist(Schema $schema): Schema
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
</span>';
}
@ -309,7 +284,7 @@ public static function infolist(Schema $schema): Schema
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
// Legacy layout (kept for fallback if tabs are disabled)
Section::make(static::text('common.settings'))
Section::make('Settings')
->schema([
ViewEntry::make('settings')
->label('')
@ -323,7 +298,7 @@ public static function infolist(Schema $schema): Schema
return ! static::usesTabbedLayout($record);
}),
Section::make(static::text('resource.snapshot_json_section'))
Section::make('Policy Snapshot (JSON)')
->schema([
ViewEntry::make('snapshot_json')
->view('filament.infolists.entries.snapshot-json')
@ -331,7 +306,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(),
TextEntry::make('snapshot_size')
->label(static::text('resource.payload_size'))
->label('Payload Size')
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
->formatStateUsing(function ($state) {
if ($state > 512000) {
@ -339,7 +314,7 @@ public static function infolist(Schema $schema): Schema
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
</span>';
}
@ -361,6 +336,11 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(function (Builder $query) {
// Quick-Workaround: Hide policies not synced in last 7 days
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
$query->where('last_synced_at', '>', now()->subDays(7));
})
->defaultSort('display_name')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
@ -369,36 +349,24 @@ public static function table(Table $table): Table
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('display_name')
->label(static::text('common.policy'))
->label('Policy')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('policy_type')
->label(static::text('common.type'))
->label('Type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('visibility_state')
->label(static::text('common.visibility'))
->badge()
->state(fn (Policy $record): string => $record->visibilityState())
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
->description(fn (Policy $record): ?string => $record->isProviderMissing()
? static::text('resource.visibility_source_unavailable_description')
: null),
Tables\Columns\TextColumn::make('category')
->label(static::text('common.category'))
->label('Category')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
->toggleable(isToggledHiddenByDefault: true),
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
Tables\Columns\TextColumn::make('restore_mode')
->label(static::text('common.restore'))
->label('Restore')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
@ -406,22 +374,19 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
Tables\Columns\TextColumn::make('platform')
->label(static::text('common.platform'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
->sortable(),
Tables\Columns\TextColumn::make('settings_status')
->label(static::text('common.settings'))
->label('Settings')
->badge()
->state(function (Policy $record) {
$latest = $record->versions->first();
$snapshot = $latest?->snapshot ?? [];
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
return $hasSettings
? static::text('resource.settings_available')
: static::text('resource.settings_missing');
return $hasSettings ? 'Available' : 'Missing';
})
->color(function (Policy $record) {
$latest = $record->versions->first();
@ -431,12 +396,12 @@ public static function table(Table $table): Table
return $hasSettings ? 'success' : 'gray';
}),
Tables\Columns\TextColumn::make('external_id')
->label(static::text('common.external_id'))
->label('External ID')
->copyable()
->limit(32)
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('last_synced_at')
->label(static::text('common.last_synced'))
->label('Last synced')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
@ -446,35 +411,27 @@ public static function table(Table $table): Table
])
->filters([
Tables\Filters\SelectFilter::make('visibility')
->label(static::text('common.visibility'))
->label('Visibility')
->options([
'active' => static::text('resource.filter_active'),
'ignored' => static::text('resource.filter_ignored'),
'provider_missing' => static::text('resource.filter_source_unavailable'),
'all' => static::text('resource.filter_all'),
'active' => 'Active',
'ignored' => 'Ignored',
])
->default('active')
->query(function (Builder $query, array $data) {
$value = $data['value'] ?? null;
if (blank($value) || $value === 'all') {
if (blank($value)) {
return;
}
if ($value === 'active') {
$query->active();
$query->whereNull('ignored_at');
return;
}
if ($value === 'ignored') {
$query->whereNotNull('ignored_at');
return;
}
if ($value === 'provider_missing') {
$query->whereNotNull('missing_from_provider_at');
}
}),
Tables\Filters\SelectFilter::make('policy_type')
@ -518,16 +475,14 @@ public static function table(Table $table): Table
ActionGroup::make([
UiEnforcement::forTableAction(
Actions\Action::make('export')
->label(static::text('resource.export_to_backup'))
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->visible(fn (Policy $record): bool => $record->ignored_at === null)
->disabled(fn (Policy $record): bool => ! $record->isCurrentBackupEligible())
->tooltip(fn (Policy $record): ?string => $record->currentBackupBlockedReasonLabel())
->form([
Forms\Components\TextInput::make('backup_name')
->label(static::text('common.backup_name'))
->label('Backup Name')
->required()
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Policy $record, array $data): void {
$tenant = static::resolveTenantContextForCurrentPanel();
@ -541,16 +496,6 @@ public static function table(Table $table): Table
abort(403);
}
if (! $record->isCurrentBackupEligible()) {
Notification::make()
->title(static::text('resource.current_backup_unavailable'))
->body($record->currentBackupBlockedReasonLabel())
->warning()
->send();
return;
}
$ids = [(int) $record->getKey()];
/** @var BulkSelectionIdentity $selection */
@ -588,7 +533,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -596,12 +541,11 @@ public static function table(Table $table): Table
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveDisabled()
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('sync')
->label(static::text('resource.sync_action_secondary'))
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
@ -635,7 +579,7 @@ public static function table(Table $table): Table
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -648,7 +592,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -660,7 +604,7 @@ public static function table(Table $table): Table
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label(static::text('resource.restore_action'))
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
@ -669,19 +613,19 @@ public static function table(Table $table): Table
$record->unignore();
Notification::make()
->title(static::text('resource.policy_restored'))
->title('Policy restored')
->success()
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('resource.restore_permission_tooltip'))
->tooltip('You do not have permission to restore policies.')
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('ignore')
->label(static::text('resource.ignore_action'))
->label('Ignore')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
@ -690,31 +634,31 @@ public static function table(Table $table): Table
$record->ignore();
Notification::make()
->title(static::text('resource.policy_ignored'))
->title('Policy ignored')
->success()
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('resource.ignore_permission_tooltip'))
->tooltip('You do not have permission to ignore policies.')
->preserveVisibility()
->apply(),
])
->label(static::text('common.more'))
->label('More')
->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_export')
->label(static::text('resource.export_to_backup'))
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->form([
Forms\Components\TextInput::make('backup_name')
->label(static::text('common.backup_name'))
->label('Backup Name')
->required()
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data): void {
$tenant = static::resolveTenantContextForCurrentPanel();
@ -730,20 +674,6 @@ public static function table(Table $table): Table
abort(403);
}
$blocked = $records->first(
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
);
if ($blocked instanceof Policy) {
Notification::make()
->title(static::text('resource.current_backup_unavailable'))
->body($blocked->currentBackupBlockedReasonLabel())
->warning()
->send();
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
@ -791,7 +721,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -802,7 +732,7 @@ public static function table(Table $table): Table
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_sync')
->label(static::text('resource.sync_action_primary'))
->label('Sync Policies')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
@ -849,7 +779,7 @@ public static function table(Table $table): Table
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -862,7 +792,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -873,7 +803,7 @@ public static function table(Table $table): Table
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label(static::text('resource.restore_bulk_action'))
->label('Restore Policies')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
@ -943,7 +873,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -954,7 +884,7 @@ public static function table(Table $table): Table
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label(static::text('resource.ignore_bulk_action'))
->label('Ignore Policies')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
@ -968,11 +898,11 @@ public static function table(Table $table): Table
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label(static::text('common.type_delete_to_confirm'))
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => static::text('common.type_delete_to_confirm_validation'),
'in' => 'Please type DELETE to confirm.',
]),
];
}
@ -1025,10 +955,10 @@ public static function table(Table $table): Table
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->body(static::text('resource.delete_queued_body', ['count' => $count]))
->body("Queued deletion for {$count} policies.")
->actions([
\Filament\Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url($runUrl),
])
->send();
@ -1037,10 +967,10 @@ public static function table(Table $table): Table
}
OperationUxPresenter::queuedToast((string) $opRun->type)
->body(static::text('resource.delete_queued_body', ['count' => $count]))
->body("Queued deletion for {$count} policies.")
->actions([
\Filament\Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url($runUrl),
])
->send();
@ -1049,10 +979,10 @@ public static function table(Table $table): Table
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
])->label(static::text('common.more')),
])->label('More'),
])
->emptyStateHeading(static::text('resource.empty_state_heading'))
->emptyStateDescription(static::text('resource.empty_state_description'))
->emptyStateHeading('No policies synced yet')
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
->emptyStateIcon('heroicon-o-arrow-path')
->emptyStateActions([
static::makeSyncAction(),
@ -1229,25 +1159,25 @@ private static function generalOverviewState(Policy $record): array
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
if (is_string($name) && $name !== '') {
$entries[] = ['key' => static::text('resource.general_field_name'), 'value' => $name];
$entries[] = ['key' => 'Name', 'value' => $name];
}
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
if (is_string($platforms) && $platforms !== '') {
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
} elseif (is_array($platforms) && $platforms !== []) {
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
}
$technologies = $snapshot['technologies'] ?? null;
if (is_string($technologies) && $technologies !== '') {
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
} elseif (is_array($technologies) && $technologies !== []) {
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
}
if (array_key_exists('templateReference', $snapshot)) {
$entries[] = ['key' => static::text('resource.general_field_template_reference'), 'value' => $snapshot['templateReference']];
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
}
$settingCount = $snapshot['settingCount']
@ -1255,29 +1185,29 @@ private static function generalOverviewState(Policy $record): array
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
if (is_int($settingCount) || is_numeric($settingCount)) {
$entries[] = ['key' => static::text('resource.general_field_setting_count'), 'value' => $settingCount];
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
}
$version = $snapshot['version'] ?? null;
if (is_string($version) && $version !== '') {
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
$entries[] = ['key' => 'Version', 'value' => $version];
} elseif (is_numeric($version)) {
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
$entries[] = ['key' => 'Version', 'value' => $version];
}
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
if (is_string($lastModified) && $lastModified !== '') {
$entries[] = ['key' => static::text('resource.general_field_last_modified'), 'value' => $lastModified];
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
}
$createdAt = $snapshot['createdDateTime'] ?? null;
if (is_string($createdAt) && $createdAt !== '') {
$entries[] = ['key' => static::text('resource.general_field_created'), 'value' => $createdAt];
$entries[] = ['key' => 'Created', 'value' => $createdAt];
}
$description = $snapshot['description'] ?? null;
if (is_string($description) && $description !== '') {
$entries[] = ['key' => static::text('resource.general_field_description'), 'value' => $description];
$entries[] = ['key' => 'Description', 'value' => $description];
}
return [
@ -1302,9 +1232,4 @@ private static function settingsTabState(Policy $record): array
return $normalized;
}
private static function text(string $key, array $replace = []): string
{
return __('localization.policy.'.$key, $replace);
}
}

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\PolicyResource;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\Policy;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
@ -40,37 +39,23 @@ private function makeCaptureSnapshotAction(): Action
{
$action = UiEnforcement::forAction(
Action::make('capture_snapshot')
->label($this->text('resource.capture_snapshot_action'))
->label('Capture snapshot')
->requiresConfirmation()
->modalHeading($this->text('resource.capture_snapshot_modal_heading'))
->modalSubheading($this->text('resource.capture_snapshot_modal_subheading').' '.$this->text('common.source_microsoft_intune'))
->disabled(fn (): bool => $this->record instanceof Policy && $this->record->isProviderMissing())
->tooltip(fn (): ?string => $this->record instanceof Policy && $this->record->isProviderMissing()
? $this->record->currentBackupBlockedReasonLabel()
: null)
->modalHeading('Capture snapshot now')
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
->form([
Forms\Components\Checkbox::make('include_assignments')
->label($this->text('resource.capture_snapshot_include_assignments'))
->label('Include assignments')
->default(true)
->helperText($this->text('resource.capture_snapshot_include_assignments_helper')),
->helperText('Captures assignment include/exclude targeting and filters.'),
Forms\Components\Checkbox::make('include_scope_tags')
->label($this->text('resource.capture_snapshot_include_scope_tags'))
->label('Include scope tags')
->default(true)
->helperText($this->text('resource.capture_snapshot_include_scope_tags_helper')),
->helperText('Captures policy scope tag IDs.'),
])
->action(function (array $data, AuditLogger $auditLogger) {
$policy = $this->record;
if ($policy instanceof Policy && $policy->isProviderMissing()) {
Notification::make()
->title($this->text('resource.capture_snapshot_unavailable_title'))
->body($policy->currentBackupBlockedReasonLabel())
->warning()
->send();
return;
}
$tenant = $policy->tenant;
$user = auth()->user();
@ -123,11 +108,11 @@ private function makeCaptureSnapshotAction(): Action
if (! $opRun->wasRecentlyCreated) {
Notification::make()
->title($this->text('resource.capture_snapshot_in_progress_title'))
->body($this->text('resource.capture_snapshot_in_progress_body'))
->title('Snapshot already in progress')
->body('An active run already exists for this policy. Opening run details.')
->actions([
\Filament\Actions\Action::make('view_run')
->label($this->text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->info()
@ -160,7 +145,7 @@ private function makeCaptureSnapshotAction(): Action
OperationUxPresenter::queuedToast('policy.capture_snapshot')
->actions([
\Filament\Actions\Action::make('view_run')
->label($this->text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -170,8 +155,7 @@ private function makeCaptureSnapshotAction(): Action
->color('primary')
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip($this->text('resource.capture_snapshot_permission_tooltip'))
->preserveDisabled()
->tooltip('You do not have permission to capture policy snapshots.')
->apply();
if (! $action instanceof Action) {
@ -180,9 +164,4 @@ private function makeCaptureSnapshotAction(): Action
return $action;
}
private function text(string $key, array $replace = []): string
{
return __('localization.policy.'.$key, $replace);
}
}

View File

@ -59,15 +59,15 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public function table(Table $table): Table
{
$restoreToIntune = Actions\Action::make('restore_to_intune')
->label($this->text('relation.restore_to_microsoft_intune'))
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => $this->text('relation.restore_heading', ['version' => $record->version_number]))
->modalSubheading($this->text('relation.restore_subheading'))
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label($this->text('common.preview_only_dry_run'))
->label('Preview only (dry-run)')
->default(true),
])
->action(function (mixed $record, array $data, RestoreService $restoreService) {
@ -77,7 +77,7 @@ public function table(Table $table): Table
if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()
->title($this->text('relation.missing_context_title'))
->title('Missing tenant or user context.')
->danger()
->send();
@ -86,7 +86,7 @@ public function table(Table $table): Table
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title($this->text('versions.different_tenant_title'))
->title('Policy version belongs to a different tenant')
->danger()
->send();
@ -103,7 +103,7 @@ public function table(Table $table): Table
);
} catch (\Throwable $throwable) {
Notification::make()
->title($this->text('relation.restore_run_failed_title'))
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
@ -112,7 +112,7 @@ public function table(Table $table): Table
}
Notification::make()
->title($this->text('relation.restore_run_started_title'))
->title('Restore run started')
->success()
->send();
@ -146,7 +146,7 @@ public function table(Table $table): Table
})
->tooltip(function (PolicyVersion $record): ?string {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
return $this->text('versions.metadata_only_tooltip');
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
}
$tenant = static::resolveTenantContextForCurrentPanel();
@ -171,11 +171,10 @@ public function table(Table $table): Table
return $table
->columns([
Tables\Columns\TextColumn::make('version_number')->label($this->text('common.version'))->sortable(),
Tables\Columns\TextColumn::make('captured_at')->label($this->text('common.captured'))->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label($this->text('common.actor')),
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('policy_type')
->label($this->text('common.type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
@ -190,8 +189,8 @@ public function table(Table $table): Table
$restoreToIntune,
])
->bulkActions([])
->emptyStateHeading($this->text('relation.no_versions_captured'))
->emptyStateDescription($this->text('relation.no_versions_captured_description'));
->emptyStateHeading('No versions captured')
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
}
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
@ -215,9 +214,4 @@ private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record):
return $resolvedRecord;
}
private function text(string $key, array $replace = []): string
{
return __('localization.policy.'.$key, $replace);
}
}

View File

@ -121,25 +121,23 @@ public static function infolist(Schema $schema): Schema
return $schema
->schema([
Infolists\Components\TextEntry::make('policy.display_name')
->label(static::text('common.policy'))
->label('Policy')
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
Infolists\Components\TextEntry::make('version_number')->label(static::text('common.version')),
Infolists\Components\TextEntry::make('version_number')->label('Version'),
Infolists\Components\TextEntry::make('policy_type')
->label(static::text('common.type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Infolists\Components\TextEntry::make('platform')
->label(static::text('common.platform'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Infolists\Components\TextEntry::make('created_by')->label(static::text('common.actor')),
Infolists\Components\TextEntry::make('captured_at')->dateTime()->label(static::text('common.captured')),
Section::make(static::text('versions.backup_quality_section'))
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
Section::make('Backup quality')
->schema([
Infolists\Components\TextEntry::make('quality_snapshot_mode')
->label(static::text('common.snapshot'))
->label('Snapshot')
->badge()
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
@ -147,27 +145,27 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Infolists\Components\TextEntry::make('quality_summary')
->label(static::text('versions.backup_quality'))
->label('Backup quality')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
Infolists\Components\TextEntry::make('quality_assignment_signal')
->label(static::text('versions.assignment_quality'))
->label('Assignment quality')
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
Infolists\Components\TextEntry::make('quality_next_action')
->label(static::text('versions.next_action'))
->label('Next action')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
Infolists\Components\TextEntry::make('quality_integrity_warning')
->label(static::text('versions.integrity_note'))
->label('Integrity note')
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
->columnSpanFull(),
Infolists\Components\TextEntry::make('quality_boundary')
->label(static::text('versions.boundary'))
->label('Boundary')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make(static::text('versions.related_context_section'))
Section::make('Related context')
->schema([
Infolists\Components\ViewEntry::make('related_context')
->label('')
@ -181,7 +179,7 @@ public static function infolist(Schema $schema): Schema
->persistTabInQueryString('tab')
->columnSpanFull()
->tabs([
Tab::make(static::text('common.settings'))
Tab::make('Normalized settings')
->id('normalized-settings')
->schema([
Infolists\Components\ViewEntry::make('normalized_settings')
@ -200,14 +198,14 @@ public static function infolist(Schema $schema): Schema
return NormalizedSettingsSurface::build($normalized, 'policy_version');
}),
]),
Tab::make(static::text('resource.tab_json'))
Tab::make('Raw JSON')
->id('raw-json')
->schema([
Infolists\Components\ViewEntry::make('snapshot_pretty')
->view('filament.infolists.entries.snapshot-json')
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
]),
Tab::make(static::text('versions.diff_tab'))
Tab::make('Diff')
->id('diff')
->schema([
Infolists\Components\ViewEntry::make('normalized_diff')
@ -228,7 +226,7 @@ public static function infolist(Schema $schema): Schema
return NormalizedDiffSurface::build($result, 'policy_version');
}),
Infolists\Components\ViewEntry::make('diff_json')
->label(static::text('versions.raw_diff_advanced'))
->label('Raw diff (advanced)')
->view('filament.infolists.entries.snapshot-json')
->state(function (PolicyVersion $record) {
$previous = $record->previous();
@ -277,11 +275,11 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
->label(static::text('versions.prune_versions'))
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription(static::text('versions.prune_modal_description'))
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
@ -293,8 +291,8 @@ public static function table(Table $table): Table
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label(static::text('versions.retention_days'))
->helperText(static::text('versions.retention_days_helper'))
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
@ -303,11 +301,11 @@ public static function table(Table $table): Table
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label(static::text('common.type_delete_to_confirm'))
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => static::text('common.type_delete_to_confirm_validation'),
'in' => 'Please type DELETE to confirm.',
]);
}
@ -365,7 +363,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('policy_version.prune')
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -374,11 +372,11 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction($bulkPruneVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('versions.manage_permission_tooltip'))
->tooltip('You do not have permission to manage policy versions.')
->apply();
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
->label(static::text('versions.restore_versions'))
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
@ -390,8 +388,8 @@ public static function table(Table $table): Table
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => static::text('versions.restore_versions_modal_heading', ['count' => $records->count()]))
->modalDescription(static::text('versions.restore_versions_modal_description'))
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
@ -440,7 +438,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('policy_version.restore')
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -449,11 +447,11 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction($bulkRestoreVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('versions.manage_permission_tooltip'))
->tooltip('You do not have permission to manage policy versions.')
->apply();
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
->label(static::text('versions.force_delete_versions'))
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
@ -465,15 +463,15 @@ public static function table(Table $table): Table
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => static::text('versions.force_delete_versions_modal_heading', ['count' => $records->count()]))
->modalDescription(static::text('versions.force_delete_versions_modal_description'))
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label(static::text('common.type_delete_to_confirm'))
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => static::text('common.type_delete_to_confirm_validation'),
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
@ -524,7 +522,7 @@ public static function table(Table $table): Table
OperationUxPresenter::queuedToast('policy_version.force_delete')
->actions([
Actions\Action::make('view_run')
->label(static::text('common.open_operation'))
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
@ -533,7 +531,7 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('versions.manage_permission_tooltip'))
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $table
@ -544,15 +542,13 @@ public static function table(Table $table): Table
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label(static::text('common.policy'))
->label('Policy')
->sortable()
->searchable()
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
Tables\Columns\TextColumn::make('version_number')
->label(static::text('common.version'))
->sortable(),
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('snapshot_mode')
->label(static::text('common.snapshot'))
->label('Snapshot')
->badge()
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
@ -560,33 +556,30 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Tables\Columns\TextColumn::make('backup_quality')
->label(static::text('versions.backup_quality'))
->label('Backup quality')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
->wrap(),
Tables\Columns\TextColumn::make('policy_type')
->label(static::text('common.type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('platform')
->label(static::text('common.platform'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('created_by')->label(static::text('common.actor'))->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('captured_at')->label(static::text('common.captured'))->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('policy_type')
->label(static::text('common.type'))
->label('Type')
->options(FilterOptionCatalog::policyTypes())
->searchable(),
Tables\Filters\SelectFilter::make('platform')
->label(static::text('common.platform'))
->options(FilterOptionCatalog::platforms())
->searchable(),
FilterPresets::dateRange('captured_at', static::text('common.captured'), 'captured_at'),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
FilterPresets::archived(),
])
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
@ -597,12 +590,12 @@ public static function table(Table $table): Table
Actions\ActionGroup::make([
(function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard')
->label(static::text('versions.restore_via_wizard'))
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => static::text('versions.restore_via_wizard_modal_heading', ['version' => $record->version_number]))
->modalSubheading(static::text('versions.restore_via_wizard_modal_subheading'))
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->visible(function (): bool {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
@ -653,11 +646,11 @@ public static function table(Table $table): Table
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return static::text('versions.restore_run_permission_tooltip');
return 'You do not have permission to create restore runs.';
}
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
return static::text('versions.metadata_only_tooltip');
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
}
return null;
@ -683,8 +676,8 @@ public static function table(Table $table): Table
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
Notification::make()
->title(static::text('versions.restore_disabled_metadata_title'))
->body(static::text('versions.restore_disabled_metadata_body'))
->title('Restore disabled for metadata-only snapshot')
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
->warning()
->send();
@ -693,7 +686,7 @@ public static function table(Table $table): Table
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
->title(static::text('versions.different_tenant_title'))
->title('Policy version belongs to a different tenant')
->danger()
->send();
@ -704,7 +697,7 @@ public static function table(Table $table): Table
if (! $policy) {
Notification::make()
->title(static::text('versions.missing_policy_title'))
->title('Policy could not be found for this version')
->danger()
->send();
@ -713,10 +706,11 @@ public static function table(Table $table): Table
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => static::text('versions.backup_set_name', [
'policy' => $policy->display_name,
'version' => $record->version_number,
]),
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$record->version_number
),
'created_by' => $user?->email,
'status' => 'completed',
'item_count' => 1,
@ -794,7 +788,7 @@ public static function table(Table $table): Table
})(),
(function (): Actions\Action {
$action = Actions\Action::make('archive')
->label(static::text('versions.archive'))
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
@ -821,7 +815,7 @@ public static function table(Table $table): Table
}
Notification::make()
->title(static::text('versions.archived_title'))
->title('Policy version archived')
->success()
->send();
});
@ -829,14 +823,14 @@ public static function table(Table $table): Table
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('versions.manage_permission_tooltip'))
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
(function (): Actions\Action {
$action = Actions\Action::make('forceDelete')
->label(static::text('versions.force_delete'))
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
@ -863,7 +857,7 @@ public static function table(Table $table): Table
$record->forceDelete();
Notification::make()
->title(static::text('versions.force_deleted_title'))
->title('Policy version permanently deleted')
->success()
->send();
});
@ -871,7 +865,7 @@ public static function table(Table $table): Table
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('versions.manage_permission_tooltip'))
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
@ -879,7 +873,7 @@ public static function table(Table $table): Table
(function (): Actions\Action {
$action = Actions\Action::make('restore')
->label(static::text('common.restore'))
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
@ -906,7 +900,7 @@ public static function table(Table $table): Table
}
Notification::make()
->title(static::text('versions.restored_title'))
->title('Policy version restored')
->success()
->send();
});
@ -914,13 +908,13 @@ public static function table(Table $table): Table
UiEnforcement::forAction($action)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(static::text('versions.manage_permission_tooltip'))
->tooltip('You do not have permission to manage policy versions.')
->apply();
return $action;
})(),
])
->label(static::text('common.more'))
->label('More')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
])
@ -929,14 +923,14 @@ public static function table(Table $table): Table
$bulkPruneVersions,
$bulkRestoreVersions,
$bulkForceDeleteVersions,
])->label(static::text('common.more')),
])->label('More'),
])
->emptyStateHeading(static::text('versions.empty_state_heading'))
->emptyStateDescription(static::text('versions.empty_state_description'))
->emptyStateHeading('No policy versions')
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
->emptyStateIcon('heroicon-o-clock')
->emptyStateActions([
Actions\Action::make('open_backup_sets')
->label(static::text('versions.open_backup_sets'))
->label('Open backup sets')
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
->color('gray'),
]);
@ -1022,7 +1016,7 @@ public static function relatedContextEntries(PolicyVersion $record): array
private static function primaryRelatedAction(): Actions\Action
{
return Actions\Action::make('primary_drill_down')
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? static::text('versions.related_record_fallback'))
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
->color('gray');
@ -1038,10 +1032,10 @@ private static function policyVersionAssignmentQualityLabel(PolicyVersion $recor
$summary = static::policyVersionQualitySummary($record);
return match (true) {
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => static::text('versions.assignment_fetch_failed_orphaned'),
$summary->hasAssignmentIssues => static::text('versions.assignment_fetch_failed'),
$summary->hasOrphanedAssignments => static::text('versions.assignment_orphaned'),
default => static::text('versions.assignment_no_issues'),
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
default => 'No assignment issues were detected from captured metadata.',
};
}
@ -1071,11 +1065,6 @@ private static function resolvedDisplayName(PolicyVersion $record): string
return $displayName;
}
return static::text('versions.fallback_display_name', ['version' => (int) $record->version_number]);
}
private static function text(string $key, array $replace = []): string
{
return __('localization.policy.'.$key, $replace);
return sprintf('Version %d', (int) $record->version_number);
}
}

View File

@ -1473,13 +1473,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', function ($policyQuery): void {
$policyQuery
->whereNull('ignored_at')
->orWhereNotNull('missing_from_provider_at');
});
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
})
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
@ -1503,9 +1499,6 @@ private static function restoreItemOptionData(?int $backupSetId): array
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$providerMissingNote = $item->policy?->missing_from_provider_at
? 'current state: provider missing; historical restore available'
: null;
$options[$item->id] = $displayName;
@ -1515,7 +1508,6 @@ private static function restoreItemOptionData(?int $backupSetId): array
$platform,
'quality: '.$qualitySummary->compactSummary,
"restore: {$restore}",
$providerMissingNote,
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
@ -1548,13 +1540,9 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', function ($policyQuery): void {
$policyQuery
->whereNull('ignored_at')
->orWhereNotNull('missing_from_provider_at');
});
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
})
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
->with(['policy:id,display_name'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
@ -1671,7 +1659,6 @@ private static function restoreItemSelectionLabel(BackupItem $item): string
return implode(' • ', array_filter([
$item->resolvedDisplayName(),
$summary->compactSummary,
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
]));
}

View File

@ -10,7 +10,6 @@
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\TenantReviewSection;
@ -27,7 +26,6 @@
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReviewPackStatus;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
@ -236,7 +234,6 @@ public static function infolist(Schema $schema): Schema
Section::make(__('localization.review.sections'))
->schema([
RepeatableEntry::make('sections')
->state(fn (TenantReview $record): array => static::visibleSections($record))
->hiddenLabel()
->schema([
TextEntry::make('title'),
@ -266,17 +263,6 @@ public static function infolist(Schema $schema): Schema
]);
}
/**
* @return array<int, TenantReviewSection>
*/
private static function visibleSections(TenantReview $record): array
{
return $record->sections
->reject(fn (TenantReviewSection $section): bool => static::isCustomerWorkspaceMode() && $section->isControlInterpretation())
->values()
->all();
}
public static function table(Table $table): Table
{
$exportExecutivePackAction = UiEnforcement::forTableAction(
@ -654,10 +640,6 @@ private static function summaryPresentation(TenantReview $record): array
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
$controlInterpretation = is_array($summary['control_interpretation'] ?? null)
? $summary['control_interpretation']
: [];
$packagePresentation = static::governancePackagePresentation($record);
if ($findingOutcomeSummary !== null) {
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
@ -674,9 +656,11 @@ private static function summaryPresentation(TenantReview $record): array
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
'control_interpretation' => $controlInterpretation,
'governance_package' => $packagePresentation,
'metrics' => static::isCustomerWorkspaceMode() ? static::customerWorkspaceMetrics($record, $summary, $packagePresentation) : [
'metrics' => static::isCustomerWorkspaceMode() ? [
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
] : [
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
@ -687,157 +671,6 @@ private static function summaryPresentation(TenantReview $record): array
];
}
/**
* @param array<string, mixed> $summary
* @param array<string, mixed> $packagePresentation
* @return array<int, array{label:string,value:string}>
*/
private static function customerWorkspaceMetrics(TenantReview $record, array $summary, array $packagePresentation): array
{
$acceptedRisk = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
return [
['label' => __('localization.review.governance_package'), 'value' => (string) ($packagePresentation['availability']['label'] ?? __('localization.review.governance_package_unavailable'))],
['label' => __('localization.review.review_status'), 'value' => static::customerReviewStatusLabel($record)],
['label' => __('localization.review.evidence_status'), 'value' => static::customerEvidenceStatusLabel($record)],
['label' => __('localization.review.accepted_risk_status'), 'value' => static::customerAcceptedRiskStatusLabel($acceptedRisk)],
['label' => __('localization.review.last_review'), 'value' => $record->published_at?->format('Y-m-d') ?? __('localization.review.pending')],
];
}
private static function customerReviewStatusLabel(TenantReview $record): string
{
if ($record->isPublished() && (string) $record->completeness_state === TenantReviewCompletenessState::Complete->value) {
return __('localization.review.review_completed');
}
if ($record->isPublished()) {
return __('localization.review.review_requires_attention');
}
return Str::headline((string) $record->status);
}
private static function customerEvidenceStatusLabel(TenantReview $record): string
{
$snapshot = $record->evidenceSnapshot;
$tenant = $record->tenant;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot) {
return __('localization.review.evidence_pending');
}
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return __('localization.review.evidence_restricted');
}
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
return __('localization.review.evidence_expired');
}
return __('localization.review.evidence_available');
}
/**
* @param array<string, mixed> $acceptedRisk
*/
private static function customerAcceptedRiskStatusLabel(array $acceptedRisk): string
{
$warningCount = (int) ($acceptedRisk['warning_count'] ?? 0);
$statusMarkedCount = (int) ($acceptedRisk['status_marked_count'] ?? 0);
if ($warningCount > 0) {
return __('localization.review.accepted_risk_follow_up');
}
if ($statusMarkedCount > 0) {
return __('localization.review.accepted_risk_on_record', ['count' => $statusMarkedCount]);
}
return __('localization.review.accepted_risk_none');
}
/**
* @return array<string, mixed>
*/
private static function governancePackagePresentation(TenantReview $record): array
{
$summary = is_array($record->summary) ? $record->summary : [];
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
if ($package === []) {
return [];
}
return array_merge($package, [
'availability' => static::governancePackageAvailability($record),
'delivery_note' => __('localization.review.governance_package_delivery_note'),
]);
}
/**
* @return array{state:string,label:string,description:string}
*/
private static function governancePackageAvailability(TenantReview $record): array
{
$pack = $record->currentExportReviewPack;
$tenant = $record->tenant;
$user = auth()->user();
$controlInterpretation = $record->controlInterpretation();
$limitations = is_array($controlInterpretation['limitations'] ?? null) ? $controlInterpretation['limitations'] : [];
$isPartialReview = in_array((string) $record->completeness_state, [
TenantReviewCompletenessState::Partial->value,
TenantReviewCompletenessState::Stale->value,
], true) || $limitations !== [];
if (! $pack instanceof ReviewPack) {
return [
'state' => 'unavailable',
'label' => __('localization.review.governance_package_unavailable'),
'description' => __('localization.review.governance_package_unavailable_description'),
];
}
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return [
'state' => 'blocked',
'label' => __('localization.review.governance_package_blocked'),
'description' => __('localization.review.governance_package_blocked_description'),
];
}
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
return [
'state' => 'expired',
'label' => __('localization.review.governance_package_expired'),
'description' => __('localization.review.governance_package_expired_description'),
];
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return [
'state' => 'unavailable',
'label' => __('localization.review.governance_package_unavailable'),
'description' => __('localization.review.governance_package_not_ready_description'),
];
}
if ($isPartialReview) {
return [
'state' => 'partial',
'label' => __('localization.review.governance_package_partial'),
'description' => __('localization.review.governance_package_partial_description'),
];
}
return [
'state' => 'available',
'label' => __('localization.review.governance_package_available'),
'description' => __('localization.review.governance_package_available_description'),
];
}
/**
* @return array<int, array{title:string,label:string,url:?string,description:string}>
*/
@ -880,7 +713,9 @@ private static function summaryContextLinks(TenantReview $record, bool $customer
: null;
if ($customerWorkspaceMode && $evidenceUrl !== null) {
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record));
$evidenceUrl = static::appendQuery($evidenceUrl, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
]);
}
$links[] = [
@ -905,24 +740,6 @@ private static function sectionPresentation(TenantReviewSection $section): array
$render = is_array($section->render_payload) ? $section->render_payload : [];
$review = $section->tenantReview;
$tenant = $section->tenant;
$links = [];
if ($section->isControlInterpretation() && $review instanceof TenantReview && $tenant instanceof Tenant && $review->evidenceSnapshot instanceof EvidenceSnapshot) {
$user = auth()->user();
if ($user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
$evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $tenant);
if (static::isCustomerWorkspaceMode()) {
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($review));
}
$links[] = [
'label' => __('localization.review.view_evidence_snapshot'),
'url' => $evidenceUrl,
];
}
}
return [
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
@ -940,8 +757,7 @@ private static function sectionPresentation(TenantReviewSection $section): array
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
'is_control_interpretation' => $section->isControlInterpretation(),
'links' => $links,
'links' => [],
];
}
@ -995,19 +811,6 @@ private static function isCustomerWorkspaceMode(): bool
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
/**
* @return array<string, mixed>
*/
private static function customerWorkspaceEvidenceQuery(TenantReview $record): array
{
return array_filter([
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => (int) $record->getKey(),
'interpretation_version' => $record->controlInterpretationVersion(),
'tenant_filter_id' => request()->query('tenant_filter_id'),
], static fn (mixed $value): bool => $value !== null && $value !== '');
}
/**
* @param array<string, mixed> $query
*/

View File

@ -355,7 +355,7 @@ private function archiveReviewAction(): Actions\Action
private function downloadCurrentReviewPackAction(): Actions\Action
{
return Actions\Action::make('download_current_review_pack')
->label(__('localization.review.download_governance_package'))
->label(__('localization.review.download_current_review_pack'))
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
@ -388,9 +388,6 @@ private function currentReviewPackDownloadUrl(): ?string
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => (int) $this->record->getKey(),
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => $this->record->controlInterpretationVersion(),
]);
}
@ -447,9 +444,7 @@ private function auditCustomerWorkspaceOpen(): void
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => request()->query('tenant_filter_id'),
'interpretation_version' => $this->record->controlInterpretationVersion(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,

View File

@ -59,9 +59,6 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
? (int) $reviewPack->tenant_review_id
: null,
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
'review_id' => $request->query('review_id'),
'tenant_filter_id' => $request->query('tenant_filter_id'),
'interpretation_version' => $request->query('interpretation_version'),
],
],
actor: $user,

View File

@ -242,33 +242,12 @@ public function handle(
continue;
}
if (! $policy->isCurrentBackupEligible()) {
$reasonCode = match ($policy->currentBackupBlockedReason()) {
Policy::VISIBILITY_PROVIDER_MISSING => 'policy_provider_missing',
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy_ignored_locally',
default => 'policy_not_current_backup_eligible',
};
$reason = $policy->currentBackupBlockedReasonLabel()
?? 'Policy is not eligible for current backup capture.';
$newBackupFailures[] = [
'policy_id' => $policyId,
'reason' => RunFailureSanitizer::sanitizeMessage($reason),
'status' => null,
'reason_code' => $reasonCode,
];
$didMutateBackupSet = true;
if ($policy->ignored_at) {
$operationRunService->incrementSummaryCounts($this->operationRun, [
'processed' => 1,
'failed' => 1,
'skipped' => 1,
]);
$runFailuresForOperationRun[] = [
'code' => str_replace('_', '.', $reasonCode),
'message' => RunFailureSanitizer::sanitizeMessage($reason),
];
continue;
}

View File

@ -120,49 +120,6 @@ public function handle(OperationRunService $operationRunService): void
continue;
}
if (! $policy->isCurrentBackupEligible()) {
$failed++;
$reasonCode = match ($policy->currentBackupBlockedReason()) {
Policy::VISIBILITY_PROVIDER_MISSING => 'policy.provider_missing',
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy.ignored_locally',
default => 'policy.not_current_backup_eligible',
};
$failures[] = [
'code' => $reasonCode,
'message' => $policy->currentBackupBlockedReasonLabel()
?? "Policy {$policyId} is not eligible for current backup capture.",
];
if ($failed > $failureThreshold) {
$backupSet->update([
'status' => 'failed',
'item_count' => $succeeded,
]);
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => $totalItems,
'processed' => $itemCount,
'succeeded' => $succeeded,
'failed' => $failed,
'created' => $succeeded,
],
failures: array_merge($failures, [
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
]),
);
}
return;
}
continue;
}
// Get latest version for snapshot
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
@ -258,15 +215,6 @@ public function handle(OperationRunService $operationRunService): void
$outcome = OperationRunOutcome::Failed->value;
}
$backupSet->update([
'status' => match ($outcome) {
OperationRunOutcome::Failed->value => 'failed',
OperationRunOutcome::PartiallySucceeded->value => 'partial',
default => 'completed',
},
'item_count' => $succeeded,
]);
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,

View File

@ -21,6 +21,7 @@
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
@ -97,17 +98,6 @@ public function table(Table $table): Table
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
->sortable(),
TextColumn::make('visibility_state')
->label('Visibility')
->badge()
->state(fn (Policy $record): string => $record->visibilityState())
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
->description(fn (Policy $record): ?string => $record->isCurrentBackupEligible()
? null
: $record->currentBackupBlockedReasonLabel()),
TextColumn::make('external_id')
->label('External ID')
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
@ -156,7 +146,7 @@ public function table(Table $table): Table
'90' => 'Within 90 days',
'any' => 'Any time',
])
->default('any')
->default('7')
->query(function (Builder $query, array $data): Builder {
$value = (string) ($data['value'] ?? '7');
@ -168,28 +158,14 @@ public function table(Table $table): Table
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
}),
SelectFilter::make('visibility')
->label('Visibility')
->options([
'active' => 'Active',
'ignored' => 'Ignored locally',
'provider_missing' => 'Provider missing',
'all' => 'All',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (blank($value) || $value === 'all') {
return $query;
}
return match ($value) {
'active' => $query->active(),
'ignored' => $query->whereNotNull('ignored_at'),
'provider_missing' => $query->whereNotNull('missing_from_provider_at'),
default => $query,
};
}),
TernaryFilter::make('ignored')
->label('Ignored')
->nullable()
->queries(
true: fn (Builder $query) => $query->whereNotNull('ignored_at'),
false: fn (Builder $query) => $query->whereNull('ignored_at'),
)
->default(false),
SelectFilter::make('has_versions')
->label('Has versions')
->options([
@ -212,7 +188,6 @@ public function table(Table $table): Table
])
->emptyStateHeading('No matching policies available')
->emptyStateDescription('Adjust the current filters or sync additional policies before adding them to this backup set.')
->checkIfRecordIsSelectableUsing(fn (Policy $record): bool => $record->isCurrentBackupEligible())
->bulkActions([
BulkAction::make('add_selected_to_backup_set')
->label('Add selected')
@ -310,20 +285,6 @@ public function table(Table $table): Table
sort($policyIds);
$blocked = $records->first(
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
);
if ($blocked instanceof Policy) {
Notification::make()
->title('Current backup unavailable')
->body($blocked->currentBackupBlockedReasonLabel())
->warning()
->send();
return;
}
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($policyIds);

View File

@ -4,7 +4,6 @@
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Concerns\InteractsWithODataTypes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -16,21 +15,12 @@ class Policy extends Model
use HasFactory;
use InteractsWithODataTypes;
public const VISIBILITY_ACTIVE = 'active';
public const VISIBILITY_IGNORED_LOCALLY = 'ignored_locally';
public const VISIBILITY_PROVIDER_MISSING = 'provider_missing';
public const VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING = 'ignored_locally_provider_missing';
protected $guarded = [];
protected $casts = [
'metadata' => 'array',
'last_synced_at' => 'datetime',
'ignored_at' => 'datetime',
'missing_from_provider_at' => 'datetime',
];
public function tenant(): BelongsTo
@ -48,77 +38,16 @@ public function backupItems(): HasMany
return $this->hasMany(BackupItem::class);
}
public function scopeActive(Builder $query): Builder
public function scopeActive($query)
{
return $query
->whereNull('ignored_at')
->whereNull('missing_from_provider_at');
return $query->whereNull('ignored_at');
}
public function scopeIgnored(Builder $query): Builder
public function scopeIgnored($query)
{
return $query->whereNotNull('ignored_at');
}
public function scopeProviderMissing(Builder $query): Builder
{
return $query->whereNotNull('missing_from_provider_at');
}
public function scopeCurrentBackupEligible(Builder $query): Builder
{
return $query
->whereNull('ignored_at')
->whereNull('missing_from_provider_at');
}
public function isIgnoredLocally(): bool
{
return $this->ignored_at !== null;
}
public function isProviderMissing(): bool
{
return $this->missing_from_provider_at !== null;
}
public function visibilityState(): string
{
return match (true) {
$this->isIgnoredLocally() && $this->isProviderMissing() => self::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING,
$this->isIgnoredLocally() => self::VISIBILITY_IGNORED_LOCALLY,
$this->isProviderMissing() => self::VISIBILITY_PROVIDER_MISSING,
default => self::VISIBILITY_ACTIVE,
};
}
public function isCurrentBackupEligible(): bool
{
return ! $this->isIgnoredLocally() && ! $this->isProviderMissing();
}
public function currentBackupBlockedReason(): ?string
{
if ($this->isProviderMissing()) {
return self::VISIBILITY_PROVIDER_MISSING;
}
if ($this->isIgnoredLocally()) {
return self::VISIBILITY_IGNORED_LOCALLY;
}
return null;
}
public function currentBackupBlockedReasonLabel(): ?string
{
return match ($this->currentBackupBlockedReason()) {
self::VISIBILITY_PROVIDER_MISSING => 'Provider missing - current provider-backed capture is unavailable.',
self::VISIBILITY_IGNORED_LOCALLY => 'Ignored locally - restore local visibility before fresh capture.',
default => null,
};
}
public function ignore(): void
{
$this->update(['ignored_at' => now()]);

View File

@ -5,7 +5,6 @@
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
use Illuminate\Database\Eloquent\Builder;
@ -206,63 +205,4 @@ public function canonicalControlReferences(): array
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: [];
}
/**
* @return array<string, mixed>
*/
public function controlInterpretation(): array
{
$summary = is_array($this->summary) ? $this->summary : [];
$interpretation = $summary['control_interpretation'] ?? [];
return is_array($interpretation) ? $interpretation : [];
}
public function controlInterpretationVersion(): ?string
{
$version = $this->controlInterpretation()['version_key'] ?? null;
return is_string($version) && trim($version) !== '' ? $version : null;
}
/**
* @return list<array<string, mixed>>
*/
public function controlInterpretationControls(): array
{
$controls = $this->controlInterpretation()['controls'] ?? [];
return is_array($controls)
? array_values(array_filter($controls, static fn (mixed $control): bool => is_array($control)))
: [];
}
/**
* @return array<string, int>
*/
public function controlInterpretationLimitationCounts(): array
{
$counts = $this->controlInterpretation()['limitation_counts'] ?? [];
if (! is_array($counts)) {
return [];
}
return collect($counts)
->mapWithKeys(static fn (mixed $count, string|int $key): array => [(string) $key => (int) $count])
->all();
}
public function controlInterpretationSection(): ?TenantReviewSection
{
if ($this->relationLoaded('sections')) {
$section = $this->sections->firstWhere('section_key', ComplianceEvidenceMappingV1::SECTION_KEY);
return $section instanceof TenantReviewSection ? $section : null;
}
return $this->sections()
->where('section_key', ComplianceEvidenceMappingV1::SECTION_KEY)
->first();
}
}

View File

@ -5,7 +5,6 @@
namespace App\Models;
use App\Support\TenantReviewCompletenessState;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -68,26 +67,4 @@ public function completenessEnum(): TenantReviewCompletenessState
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
?? TenantReviewCompletenessState::Missing;
}
public function isControlInterpretation(): bool
{
return (string) $this->section_key === ComplianceEvidenceMappingV1::SECTION_KEY;
}
/**
* @return list<array<string, mixed>>
*/
public function controlInterpretationEntries(): array
{
if (! $this->isControlInterpretation()) {
return [];
}
$renderPayload = is_array($this->render_payload) ? $this->render_payload : [];
$entries = $renderPayload['entries'] ?? [];
return is_array($entries)
? array_values(array_filter($entries, static fn (mixed $entry): bool => is_array($entry)))
: [];
}
}

View File

@ -43,7 +43,7 @@ public function createBackupSet(
$policies = Policy::query()
->where('tenant_id', $tenant->id)
->whereIn('id', $policyIds)
->currentBackupEligible()
->whereNull('ignored_at')
->get();
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
@ -184,7 +184,7 @@ public function addPoliciesToSet(
$policies = Policy::query()
->where('tenant_id', $tenant->id)
->whereIn('id', $policyIds)
->currentBackupEligible()
->whereNull('ignored_at')
->get();
$metadata = $backupSet->metadata ?? [];

View File

@ -11,7 +11,6 @@
use App\Services\Graph\GraphLogger;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Audit\AuditActionId;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Arr;
@ -26,7 +25,6 @@ public function __construct(
private readonly ?ProviderConnectionResolver $providerConnections = null,
private readonly ?ProviderGateway $providerGateway = null,
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
private readonly ?AuditLogger $auditLogger = null,
) {}
/**
@ -56,8 +54,6 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
$synced = [];
$failures = [];
$successfulPolicyTypes = [];
$observedExternalIdsByPolicyType = [];
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
@ -114,9 +110,6 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
continue;
}
$successfulPolicyTypes[$policyType] = true;
$observedExternalIdsByPolicyType[$policyType] ??= [];
foreach ($response->data as $policyData) {
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
@ -124,8 +117,6 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
continue;
}
$externalId = (string) $externalId;
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
if ($canonicalPolicyType !== $policyType) {
@ -136,60 +127,52 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') {
Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
continue;
}
}
$observedExternalIdsByPolicyType[$policyType][] = $externalId;
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
tenant: $tenant,
tenantId: $tenant->id,
externalId: $externalId,
policyType: $policyType,
);
$this->reclassifyConfigurationPoliciesIfNeeded(
tenant: $tenant,
tenantId: $tenant->id,
externalId: $externalId,
policyType: $policyType,
);
$policy = Policy::query()->firstOrNew([
'tenant_id' => $tenant->id,
'external_id' => $externalId,
'policy_type' => $policyType,
]);
$wasProviderMissing = $policy->exists && $policy->missing_from_provider_at !== null;
$policy->forceFill([
'workspace_id' => $tenant->workspace_id,
'display_name' => $displayName,
'platform' => $policyPlatform,
'last_synced_at' => now(),
'missing_from_provider_at' => null,
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
$policy = Policy::updateOrCreate(
[
'tenant_id' => $tenant->id,
'external_id' => $externalId,
'policy_type' => $policyType,
],
[
'workspace_id' => $tenant->workspace_id,
'display_name' => $displayName,
'platform' => $policyPlatform,
'last_synced_at' => now(),
'ignored_at' => null,
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
]
);
$synced[] = $policy->id;
}
}
$this->markProviderMissingPolicies(
tenant: $tenant,
policyTypes: array_keys($successfulPolicyTypes),
observedExternalIdsByPolicyType: $observedExternalIdsByPolicyType,
);
return [
'synced' => $synced,
'failures' => $failures,
@ -355,7 +338,7 @@ private function isEnrollmentNotificationItem(array $policyData): bool
], true);
}
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$enrollmentTypes = [
'enrollmentRestriction',
@ -370,54 +353,45 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(Tenant $tenan
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
$this->markSiblingPoliciesProviderMissing(
tenant: $tenant,
externalId: $externalId,
policyTypes: $enrollmentTypes,
exceptPolicyType: $policyType,
);
Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $enrollmentTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->first();
if (! $existingWrong) {
return;
}
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
$existingWrong->forceFill([
'policy_type' => $policyType,
'missing_from_provider_at' => null,
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $existingWrong,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
private function reclassifyConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
{
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
@ -426,154 +400,44 @@ private function reclassifyConfigurationPoliciesIfNeeded(Tenant $tenant, string
}
$existingCorrect = Policy::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->where('policy_type', $policyType)
->first();
if ($existingCorrect) {
$this->markSiblingPoliciesProviderMissing(
tenant: $tenant,
externalId: $externalId,
policyTypes: $configurationTypes,
exceptPolicyType: $policyType,
);
Policy::query()
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->update(['ignored_at' => now()]);
return;
}
$existingWrong = Policy::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->where('external_id', $externalId)
->whereIn('policy_type', $configurationTypes)
->where('policy_type', '!=', $policyType)
->whereNull('ignored_at')
->first();
if (! $existingWrong) {
return;
}
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
$existingWrong->forceFill([
'policy_type' => $policyType,
'missing_from_provider_at' => null,
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $existingWrong,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
PolicyVersion::query()
->where('policy_id', $existingWrong->id)
->update(['policy_type' => $policyType]);
}
/**
* @param array<int, string> $policyTypes
*/
private function markSiblingPoliciesProviderMissing(Tenant $tenant, string $externalId, array $policyTypes, string $exceptPolicyType): void
{
$timestamp = now();
Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $externalId)
->whereIn('policy_type', $policyTypes)
->where('policy_type', '!=', $exceptPolicyType)
->whereNull('missing_from_provider_at')
->get()
->each(function (Policy $policy) use ($tenant, $timestamp): void {
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingDetected,
transitionAt: $timestamp,
);
});
}
/**
* @param array<int, string> $policyTypes
* @param array<string, array<int, string>> $observedExternalIdsByPolicyType
*/
private function markProviderMissingPolicies(Tenant $tenant, array $policyTypes, array $observedExternalIdsByPolicyType): void
{
foreach ($policyTypes as $policyType) {
if (! is_string($policyType) || $policyType === '') {
continue;
}
$observedExternalIds = array_values(array_unique(array_filter(
array_map('strval', $observedExternalIdsByPolicyType[$policyType] ?? []),
static fn (string $externalId): bool => $externalId !== '',
)));
$query = Policy::query()
->where('tenant_id', $tenant->id)
->where('policy_type', $policyType)
->whereNull('missing_from_provider_at');
if ($observedExternalIds !== []) {
$query->whereNotIn('external_id', $observedExternalIds);
}
$timestamp = now();
$query->get()
->each(function (Policy $policy) use ($tenant, $timestamp): void {
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingDetected,
transitionAt: $timestamp,
);
});
}
}
private function auditProviderPresenceTransition(
Tenant $tenant,
Policy $policy,
AuditActionId $action,
mixed $transitionAt = null,
): void {
$transitionAt ??= now();
$this->auditLogger()->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => [
'policy_id' => (int) $policy->getKey(),
'external_id' => (string) $policy->external_id,
'policy_type' => (string) $policy->policy_type,
'transition_at' => method_exists($transitionAt, 'toIso8601String')
? $transitionAt->toIso8601String()
: (string) $transitionAt,
'source' => 'policy_sync',
],
],
resourceType: 'policy',
resourceId: (string) $policy->getKey(),
targetLabel: (string) $policy->display_name,
status: 'success',
);
}
private function auditLogger(): AuditLogger
{
return $this->auditLogger ?? app(AuditLogger::class);
}
/**
* Re-fetch a single policy from Graph and update local metadata.
*/
@ -642,23 +506,13 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
$platform = $payload['platform'] ?? $policy->platform;
$wasProviderMissing = $policy->missing_from_provider_at !== null;
$policy->forceFill([
'display_name' => $displayName,
'platform' => $platform,
'last_synced_at' => now(),
'missing_from_provider_at' => null,
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
])->save();
if ($wasProviderMissing) {
$this->auditProviderPresenceTransition(
tenant: $tenant,
policy: $policy,
action: AuditActionId::PolicyProviderMissingCleared,
);
}
}
/**

View File

@ -38,16 +38,6 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
$completeness = $this->readinessGate->completenessForSections($sections);
$status = $this->readinessGate->statusForSections($sections);
$executiveSummarySection = collect($sections)
->firstWhere('section_key', 'executive_summary');
$controlInterpretationSection = collect($sections)
->firstWhere('section_key', 'control_interpretation');
$openRisksSection = collect($sections)
->firstWhere('section_key', 'open_risks');
$acceptedRisksSection = collect($sections)
->firstWhere('section_key', 'accepted_risks');
$operationsSection = collect($sections)
->firstWhere('section_key', 'operations_health');
if ($review instanceof TenantReview && $review->isPublished()) {
$status = TenantReviewStatus::Published;
@ -78,260 +68,13 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
? data_get($sections, '0.summary_payload.canonical_controls')
: [],
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
? array_merge(
data_get($controlInterpretationSection, 'summary_payload'),
[
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
? data_get($controlInterpretationSection, 'render_payload.entries')
: [],
],
)
: [],
'report_count' => 2,
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []),
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
'governance_package' => $this->governancePackageSummary(
snapshot: $snapshot,
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
),
'last_composed_at' => now()->toIso8601String(),
],
'sections' => $sections,
];
}
/**
* @param array<string, mixed> $executiveSummarySection
* @param array<string, mixed> $controlInterpretationSection
* @param array<string, mixed> $openRisksSection
* @param array<string, mixed> $acceptedRisksSection
* @return array<string, mixed>
*/
private function governancePackageSummary(
EvidenceSnapshot $snapshot,
array $executiveSummarySection,
array $controlInterpretationSection,
array $openRisksSection,
array $acceptedRisksSection,
): array {
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
? $executiveSummarySection['summary_payload']
: [];
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
? $executiveSummarySection['render_payload']
: [];
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
? $controlInterpretationSection['summary_payload']
: [];
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->take(3)
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
->values()
->all();
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
->values();
$governanceDecisionEntries = $acceptedRiskEntries
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$stableAcceptedRiskEntries = $acceptedRiskEntries
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$governanceDecisions = $governanceDecisionEntries
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
->values()
->all();
return [
'delivery_artifact_family' => 'review_pack',
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
? $controlInterpretationSummary['version_key']
: null,
'executive_summary' => $this->governancePackageExecutiveSummary(
executiveSummaryPayload: $executiveSummaryPayload,
executiveRenderPayload: $executiveRenderPayload,
controlInterpretationSummary: $controlInterpretationSummary,
acceptedRiskCount: $acceptedRiskEntries->count(),
),
'top_findings' => $openRiskEntries,
'accepted_risks' => $stableAcceptedRiskEntries->all(),
'governance_decisions' => $governanceDecisions,
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
snapshot: $snapshot,
controlInterpretationSummary: $controlInterpretationSummary,
),
'supporting_artifact_links' => [
[
'artifact_family' => 'evidence_snapshot',
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
'purpose' => 'evidence_basis',
],
[
'artifact_family' => 'review_pack',
'artifact_key' => 'review_pack:current_export',
'purpose' => 'stakeholder_delivery',
],
],
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageFindingEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Open finding'),
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageAcceptedRiskEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Accepted risk'),
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
'owner_label' => $this->ownerLabel($entry),
];
}
/**
* @param array<string, mixed> $entry
*/
private function requiresGovernanceDecisionFollowUp(array $entry): bool
{
return in_array((string) ($entry['governance_state'] ?? ''), [
'expired_exception',
'revoked_exception',
'risk_accepted_without_valid_exception',
], true);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageGovernanceDecisionEntry(array $entry): array
{
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
return [
'finding_id' => $entry['finding_id'] ?? null,
'title' => $entry['title'] ?? 'Governance decision',
'governance_state' => $governanceState,
'summary' => match ($governanceState) {
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
default => 'This governance decision needs follow-up before stakeholder delivery.',
},
];
}
/**
* @param array<string, mixed> $executiveSummaryPayload
* @param array<string, mixed> $executiveRenderPayload
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageExecutiveSummary(
array $executiveSummaryPayload,
array $executiveRenderPayload,
array $controlInterpretationSummary,
int $acceptedRiskCount,
): string {
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
->values();
if ($highlights->isNotEmpty()) {
return (string) $highlights->first();
}
return sprintf(
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
$acceptedRiskCount,
);
}
/**
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
{
return sprintf(
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
(int) $snapshot->getKey(),
(string) $snapshot->completeness_state,
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
);
}
/**
* @param array<string, mixed> $entry
*/
private function entryTitle(array $entry, string $fallback): string
{
foreach (['title', 'name', 'finding_title'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function entrySummary(array $entry, string $fallback): string
{
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function ownerLabel(array $entry): ?string
{
$owner = $entry['owner'] ?? null;
if (is_array($owner)) {
$name = $owner['name'] ?? null;
if (is_string($name) && trim($name) !== '') {
return $name;
}
}
return null;
}
}

View File

@ -81,7 +81,6 @@ public function customerWorkspaceTenantQuery(User $user, Workspace $workspace):
return Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
->whereHas('tenantReviews', fn ($query) => $query->published())
->with([
'tenantReviews' => fn ($query) => $query
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])

View File

@ -7,7 +7,6 @@
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\TenantReviewCompletenessState;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@ -16,7 +15,6 @@ final class TenantReviewSectionFactory
{
public function __construct(
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
private readonly ComplianceEvidenceMappingV1 $complianceEvidenceMapping,
) {}
/**
@ -31,11 +29,8 @@ public function make(EvidenceSnapshot $snapshot): array
$baselineItem = $this->item($items, 'baseline_drift_posture');
$operationsItem = $this->item($items, 'operations_summary');
$controlInterpretation = $this->complianceEvidenceMapping->interpret($snapshot, $findingsItem);
return [
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
$controlInterpretation['section'],
$this->openRisksSection($findingsItem),
$this->acceptedRisksSection($findingsItem),
$this->permissionPostureSection($permissionItem, $rolesItem),

View File

@ -27,9 +27,6 @@ enum AuditActionId: string
// Diagnostics / repair actions.
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
case PolicyProviderMissingDetected = 'policy.provider_missing_detected';
case PolicyProviderMissingCleared = 'policy.provider_missing_cleared';
// Managed tenant onboarding wizard.
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
@ -187,8 +184,6 @@ private static function labels(): array
self::TenantMembershipRoleChange->value => 'Tenant member role change',
self::TenantMembershipRemove->value => 'Tenant member removal',
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
self::PolicyProviderMissingDetected->value => 'Policy provider missing detected',
self::PolicyProviderMissingCleared->value => 'Policy provider missing cleared',
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
self::ManagedTenantOnboardingDraftSelected->value => 'Managed tenant onboarding draft selected',
@ -317,8 +312,6 @@ private static function summaries(): array
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
self::TenantMembershipRemove->value => 'Tenant member removed',
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection triggered',
self::PolicyProviderMissingDetected->value => 'Policy marked provider missing',
self::PolicyProviderMissingCleared->value => 'Policy provider presence restored',
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
self::WorkspaceSettingReset->value => 'Workspace setting reset',
self::BaselineProfileCreated->value => 'Baseline profile created',

View File

@ -205,8 +205,8 @@ public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySum
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
nextAction: $degradationFamilies === []
? $this->text('next_action_open_version_detail')
: $this->text('next_action_prefer_stronger_version'),
? 'Open the version detail if you need raw settings or diff context.'
: 'Prefer a stronger version or inspect the version detail before restore.',
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
@ -295,25 +295,25 @@ private function singleRecordHighlights(
$highlights = [];
if ($snapshotMode === 'metadata_only') {
$highlights[] = $this->text('quality_highlight_metadata_only');
$highlights[] = 'Metadata only';
}
if ($hasAssignmentIssues) {
$highlights[] = $this->text('quality_highlight_assignment_fetch_failed');
$highlights[] = 'Assignment fetch failed';
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
$highlights[] = $this->text('quality_highlight_assignments_captured_separately');
$highlights[] = 'Assignments captured separately';
}
if ($hasOrphanedAssignments) {
$highlights[] = $this->text('quality_highlight_orphaned_assignments');
$highlights[] = 'Orphaned assignments';
}
if ($integrityWarning !== null) {
$highlights[] = $this->text('quality_highlight_integrity_warning');
$highlights[] = 'Integrity warning';
}
if ($snapshotMode === 'unknown' && $highlights === []) {
$highlights[] = $this->text('quality_highlight_unknown_quality');
$highlights[] = 'Unknown quality';
}
return array_values(array_unique($highlights));
@ -326,9 +326,9 @@ private function compactSummaryFromHighlights(array $qualityHighlights, string $
}
return match ($snapshotMode) {
'full' => $this->text('compact_summary_full_payload'),
'unknown' => $this->text('compact_summary_unknown_quality'),
default => $this->text('compact_summary_no_degradations_detected'),
'full' => 'Full payload',
'unknown' => 'Unknown quality',
default => 'No degradations detected',
};
}
@ -336,20 +336,15 @@ private function singleRecordSummaryMessage(array $qualityHighlights, string $sn
{
if ($qualityHighlights === []) {
return match ($snapshotMode) {
'full' => $this->text('summary_full_no_degradations'),
'unknown' => $this->text('summary_unknown_quality'),
default => $this->text('summary_no_degradations'),
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
default => 'No degradations were detected.',
};
}
return implode(' • ', $qualityHighlights).'.';
}
private function text(string $key, array $replace = []): string
{
return __('localization.policy.versions.'.$key, $replace);
}
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
{
if ($totalItems === 0) {

View File

@ -43,7 +43,6 @@ final class BadgeCatalog
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
BadgeDomain::PolicyProviderPresence->value => Domains\PolicyProviderPresenceBadge::class,
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,

View File

@ -34,7 +34,6 @@ enum BadgeDomain: string
case PolicySnapshotMode = 'policy_snapshot_mode';
case PolicyRestoreMode = 'policy_restore_mode';
case PolicyRisk = 'policy_risk';
case PolicyProviderPresence = 'policy_provider_presence';
case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status';

View File

@ -1,22 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\Policy;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class PolicyProviderPresenceBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
Policy::VISIBILITY_ACTIVE => new BadgeSpec(__('localization.policy.badges.active'), 'success', 'heroicon-m-check-circle'),
Policy::VISIBILITY_IGNORED_LOCALLY => new BadgeSpec(__('localization.policy.badges.ignored_locally'), 'warning', 'heroicon-m-eye-slash'),
Policy::VISIBILITY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.source_unavailable'), 'warning', 'heroicon-m-exclamation-triangle'),
Policy::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.ignored_source_unavailable'), 'danger', 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -13,8 +13,8 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'full' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_full'), 'success', 'heroicon-m-check-circle'),
'metadata_only' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_metadata_only'), 'warning', 'heroicon-m-exclamation-triangle'),
'full' => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
'metadata_only' => new BadgeSpec('Metadata only', 'warning', 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
};
}

View File

@ -120,12 +120,12 @@ private static function platform(mixed $value): TagBadgeSpec
->toString();
$label = match ($normalized) {
'windows' => __('localization.policy.common.platform_label_windows'),
'android' => __('localization.policy.common.platform_label_android'),
'ios' => __('localization.policy.common.platform_label_ios'),
'macos' => __('localization.policy.common.platform_label_macos'),
'all' => __('localization.policy.common.platform_label_all'),
'mobile' => __('localization.policy.common.platform_label_mobile'),
'windows' => 'Windows',
'android' => 'Android',
'ios' => 'iOS',
'macos' => 'macOS',
'all' => 'All',
'mobile' => 'Mobile',
default => null,
};

View File

@ -1,515 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Models\Finding;
use App\Support\TenantReviewCompletenessState;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final readonly class ComplianceEvidenceMappingV1
{
public const string VERSION_KEY = 'compliance_evidence_mapping.v1';
public const string SECTION_KEY = 'control_interpretation';
public function __construct(
private CanonicalControlCatalog $catalog,
) {}
/**
* @return array{
* summary: array<string, mixed>,
* section: array<string, mixed>
* }
*/
public function interpret(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem): array
{
$findingsSummary = $this->findingsSummary($findingsItem);
$entries = $this->findingEntries($findingsSummary);
$unresolvedEntryCount = $entries
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.status') !== 'resolved')
->count();
$controls = $this->controlDefinitions($findingsSummary, $entries);
$snapshotLimitations = $this->snapshotLimitations($snapshot, $findingsItem, $unresolvedEntryCount);
$controlSummaries = $controls
->map(fn (CanonicalControlDefinition $definition): array => $this->controlSummary(
definition: $definition,
entries: $this->entriesForControl($entries, $definition->controlKey),
snapshotLimitations: $snapshotLimitations,
))
->values()
->all();
$globalLimitations = $this->globalLimitations($controlSummaries, $snapshotLimitations, $controls->isEmpty(), $unresolvedEntryCount);
$limitationCounts = $this->limitationCounts($controlSummaries, $globalLimitations);
$summary = [
'version_key' => self::VERSION_KEY,
'display_label' => 'Compliance evidence mapping v1',
'non_certification_disclosure' => 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
'mapped_control_count' => count($controlSummaries),
'follow_up_required_count' => collect($controlSummaries)
->where('readiness_bucket', 'follow_up_required')
->count(),
'limitation_counts' => $limitationCounts,
'limitations' => $globalLimitations,
'controls' => $controlSummaries,
];
return [
'summary' => $summary,
'section' => [
'section_key' => self::SECTION_KEY,
'title' => 'Control readiness interpretation',
'sort_order' => 15,
'required' => true,
'completeness_state' => $this->sectionCompleteness($findingsItem, $controls->isEmpty(), $snapshotLimitations),
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem) ?? (string) $snapshot->fingerprint,
'summary_payload' => Arr::except($summary, ['controls']),
'render_payload' => [
'entries' => array_map(
fn (array $control): array => $this->controlExplanation($control, $snapshot),
$controlSummaries,
),
'disclosure' => $summary['non_certification_disclosure'],
'next_actions' => $this->sectionNextActions($controlSummaries, $globalLimitations),
'empty_state' => $controlSummaries === []
? 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.'
: null,
],
'measured_at' => $findingsItem?->measured_at ?? $snapshot->generated_at,
],
];
}
/**
* @return array<string, mixed>
*/
private function findingsSummary(?EvidenceSnapshotItem $findingsItem): array
{
return is_array($findingsItem?->summary_payload) ? $findingsItem->summary_payload : [];
}
/**
* @param array<string, mixed> $findingsSummary
* @return Collection<int, array<string, mixed>>
*/
private function findingEntries(array $findingsSummary): Collection
{
return collect(Arr::wrap($findingsSummary['entries'] ?? []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->values();
}
/**
* @param array<string, mixed> $findingsSummary
* @param Collection<int, array<string, mixed>> $entries
* @return Collection<int, CanonicalControlDefinition>
*/
private function controlDefinitions(array $findingsSummary, Collection $entries): Collection
{
$summaryControls = collect(Arr::wrap($findingsSummary['canonical_controls'] ?? []))
->filter(static fn (mixed $control): bool => is_array($control));
$entryControls = $entries
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
->filter(static fn (mixed $control): bool => is_array($control));
return $summaryControls
->merge($entryControls)
->map(fn (array $control): ?CanonicalControlDefinition => $this->definitionFor($control))
->filter()
->unique(static fn (CanonicalControlDefinition $definition): string => $definition->controlKey)
->sortBy(static fn (CanonicalControlDefinition $definition): string => $definition->name)
->values();
}
/**
* @param array<string, mixed> $control
*/
private function definitionFor(array $control): ?CanonicalControlDefinition
{
$controlKey = $control['control_key'] ?? null;
if (! is_string($controlKey) || trim($controlKey) === '') {
return null;
}
return $this->catalog->find($controlKey);
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return Collection<int, array<string, mixed>>
*/
private function entriesForControl(Collection $entries, string $controlKey): Collection
{
return $entries
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.control.control_key') === $controlKey)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @param list<string> $snapshotLimitations
* @return array<string, mixed>
*/
private function controlSummary(CanonicalControlDefinition $definition, Collection $entries, array $snapshotLimitations): array
{
$openEntries = $entries->filter(static fn (array $entry): bool => in_array((string) ($entry['status'] ?? ''), Finding::openStatuses(), true));
$acceptedRiskEntries = $entries->filter(static fn (array $entry): bool => (string) ($entry['status'] ?? '') === Finding::STATUS_RISK_ACCEPTED);
$governanceWarnings = $entries->filter(static fn (array $entry): bool => self::hasGovernanceWarning($entry));
$limitationFlags = $this->controlLimitations($acceptedRiskEntries->count(), $snapshotLimitations);
$readinessBucket = $this->readinessBucket(
openCount: $openEntries->count(),
acceptedRiskCount: $acceptedRiskEntries->count(),
governanceWarningCount: $governanceWarnings->count(),
limitationFlags: $limitationFlags,
);
return [
'control_key' => $definition->controlKey,
'control_name' => $definition->name,
'domain_key' => $definition->domainKey,
'readiness_bucket' => $readinessBucket,
'readiness_label' => self::readinessLabel($readinessBucket),
'limitation_flags' => $limitationFlags,
'limitation_labels' => array_map(self::limitationLabel(...), $limitationFlags),
'customer_summary' => $this->customerSummary($definition, $readinessBucket, $openEntries->count(), $acceptedRiskEntries->count()),
'evidence_basis_summary' => $this->evidenceBasisSummary($entries->count(), $openEntries->count(), $acceptedRiskEntries->count()),
'accepted_risk_summary' => $acceptedRiskEntries->isEmpty()
? null
: $this->acceptedRiskSummary($acceptedRiskEntries, $governanceWarnings->count()),
'recommended_next_action' => $this->recommendedNextAction($readinessBucket, $acceptedRiskEntries->count(), $limitationFlags),
'detail_anchor' => 'control-'.$definition->controlKey,
'supporting_finding_ids' => $entries
->pluck('id')
->filter(static fn (mixed $id): bool => is_numeric($id))
->map(static fn (mixed $id): int => (int) $id)
->values()
->all(),
'finding_count' => $entries->count(),
'open_finding_count' => $openEntries->count(),
'accepted_risk_count' => $acceptedRiskEntries->count(),
];
}
/**
* @param Collection<int, array<string, mixed>> $acceptedRiskEntries
*/
private function acceptedRiskSummary(Collection $acceptedRiskEntries, int $governanceWarningCount): string
{
if ($governanceWarningCount > 0) {
return sprintf(
'%d accepted-risk finding(s) need governance follow-up before relying on this interpretation.',
$acceptedRiskEntries->count(),
);
}
return sprintf(
'%d accepted-risk finding(s) are part of the evidence basis and qualify the readiness view.',
$acceptedRiskEntries->count(),
);
}
/**
* @param list<string> $snapshotLimitations
* @return list<string>
*/
private function controlLimitations(int $acceptedRiskCount, array $snapshotLimitations): array
{
$limitations = $snapshotLimitations;
if ($acceptedRiskCount > 0) {
$limitations[] = 'accepted_risk_influenced';
}
return array_values(array_unique($limitations));
}
/**
* @param list<string> $limitationFlags
*/
private function readinessBucket(int $openCount, int $acceptedRiskCount, int $governanceWarningCount, array $limitationFlags): string
{
if ($openCount > 0 || $governanceWarningCount > 0) {
return 'follow_up_required';
}
if ($acceptedRiskCount > 0 || $limitationFlags !== []) {
return 'review_recommended';
}
return 'evidence_on_record';
}
private function customerSummary(CanonicalControlDefinition $definition, string $readinessBucket, int $openCount, int $acceptedRiskCount): string
{
return match ($readinessBucket) {
'follow_up_required' => sprintf(
'%s needs follow-up because %d open finding(s) remain in the released evidence basis.',
$definition->name,
$openCount,
),
'review_recommended' => $acceptedRiskCount > 0
? sprintf('%s has evidence on record with accepted-risk context that should be reviewed before relying on the interpretation.', $definition->name)
: sprintf('%s has evidence on record, with limitations that should be reviewed before relying on the interpretation.', $definition->name),
default => sprintf('%s has evidence on record in this released review.', $definition->name),
};
}
private function evidenceBasisSummary(int $signalCount, int $openCount, int $acceptedRiskCount): string
{
$parts = [
sprintf('%d evidence signal(s) reference this control.', $signalCount),
];
if ($openCount > 0) {
$parts[] = sprintf('%d open finding(s) still need follow-up.', $openCount);
}
if ($acceptedRiskCount > 0) {
$parts[] = sprintf('%d accepted-risk finding(s) qualify this view.', $acceptedRiskCount);
}
return implode(' ', $parts);
}
/**
* @param list<string> $limitationFlags
*/
private function recommendedNextAction(string $readinessBucket, int $acceptedRiskCount, array $limitationFlags): string
{
if ($readinessBucket === 'follow_up_required') {
return 'Review the surfaced findings with the tenant and agree ownership plus follow-up timing.';
}
if ($acceptedRiskCount > 0) {
return 'Review the accepted-risk owner and next review date before customer delivery.';
}
if ($limitationFlags !== []) {
return 'Confirm the evidence basis and limitations before using this control as customer-facing readiness support.';
}
return 'Keep this evidence on record and revisit it during the normal review cadence.';
}
/**
* @param array<string, mixed> $control
* @return array<string, mixed>
*/
private function controlExplanation(array $control, EvidenceSnapshot $snapshot): array
{
return [
'title' => $control['control_name'],
'control_key' => $control['control_key'],
'control_name' => $control['control_name'],
'readiness_bucket' => $control['readiness_bucket'],
'readiness_label' => $control['readiness_label'],
'limitation_flags' => $control['limitation_flags'],
'limitation_labels' => $control['limitation_labels'],
'customer_summary' => $control['customer_summary'],
'evidence_basis_summary' => $control['evidence_basis_summary'],
'accepted_risk_summary' => $control['accepted_risk_summary'],
'explanation_text' => $control['customer_summary'],
'evidence_basis_items' => array_values(array_filter([
$control['evidence_basis_summary'],
$control['accepted_risk_summary'],
])),
'accepted_risk_context' => $control['accepted_risk_summary'],
'recommended_next_action' => $control['recommended_next_action'],
'proof_access_state' => $this->proofAccessState($snapshot),
'supporting_finding_ids' => $control['supporting_finding_ids'],
];
}
/**
* @param list<array<string, mixed>> $controlSummaries
* @param list<string> $globalLimitations
* @return list<string>
*/
private function sectionNextActions(array $controlSummaries, array $globalLimitations): array
{
if ($controlSummaries === []) {
return ['Review unmapped evidence before using this review for customer-facing readiness discussions.'];
}
$actions = collect($controlSummaries)
->pluck('recommended_next_action')
->filter(static fn (mixed $action): bool => is_string($action) && trim($action) !== '')
->unique()
->values()
->all();
if (in_array('unmapped', $globalLimitations, true)) {
$actions[] = 'Treat this review as partial until unmapped evidence can be interpreted.';
}
return array_values(array_unique($actions));
}
/**
* @param list<array<string, mixed>> $controlSummaries
* @param list<string> $snapshotLimitations
* @return list<string>
*/
private function globalLimitations(array $controlSummaries, array $snapshotLimitations, bool $noMappedControls, int $unresolvedEntryCount): array
{
$limitations = $snapshotLimitations;
if ($noMappedControls) {
$limitations[] = 'unmapped';
}
if ($unresolvedEntryCount > 0) {
$limitations[] = 'partial_mapping';
}
foreach ($controlSummaries as $control) {
foreach (Arr::wrap($control['limitation_flags'] ?? []) as $limitation) {
if (is_string($limitation) && trim($limitation) !== '') {
$limitations[] = $limitation;
}
}
}
return array_values(array_unique($limitations));
}
/**
* @param list<array<string, mixed>> $controlSummaries
* @param list<string> $globalLimitations
* @return array<string, int>
*/
private function limitationCounts(array $controlSummaries, array $globalLimitations): array
{
$counts = collect($controlSummaries)
->flatMap(static fn (array $control): array => Arr::wrap($control['limitation_flags'] ?? []))
->filter(static fn (mixed $limitation): bool => is_string($limitation) && trim($limitation) !== '')
->countBy()
->all();
foreach ($globalLimitations as $limitation) {
$counts[$limitation] = max((int) ($counts[$limitation] ?? 0), 1);
}
ksort($counts);
return array_map('intval', $counts);
}
/**
* @return list<string>
*/
private function snapshotLimitations(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem, int $unresolvedEntryCount): array
{
$limitations = [];
$state = (string) ($findingsItem?->state ?? $snapshot->completeness_state);
if ($state === TenantReviewCompletenessState::Stale->value || (string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
$limitations[] = 'stale_evidence';
}
if (in_array($state, [TenantReviewCompletenessState::Partial->value, TenantReviewCompletenessState::Missing->value], true)) {
$limitations[] = 'partial_mapping';
}
if ($unresolvedEntryCount > 0) {
$limitations[] = 'partial_mapping';
}
if (! $snapshot->exists || $snapshot->generated_at === null) {
$limitations[] = 'supporting_evidence_unavailable';
}
return array_values(array_unique($limitations));
}
/**
* @param list<string> $snapshotLimitations
*/
private function sectionCompleteness(?EvidenceSnapshotItem $findingsItem, bool $noMappedControls, array $snapshotLimitations): string
{
if (! $findingsItem instanceof EvidenceSnapshotItem) {
return TenantReviewCompletenessState::Missing->value;
}
if (in_array('stale_evidence', $snapshotLimitations, true)) {
return TenantReviewCompletenessState::Stale->value;
}
if ($noMappedControls || in_array('partial_mapping', $snapshotLimitations, true)) {
return TenantReviewCompletenessState::Partial->value;
}
return TenantReviewCompletenessState::tryFrom((string) $findingsItem->state)?->value
?? TenantReviewCompletenessState::Missing->value;
}
private function proofAccessState(EvidenceSnapshot $snapshot): string
{
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
return 'expired';
}
if (! $snapshot->exists || $snapshot->generated_at === null) {
return 'unavailable';
}
return 'available';
}
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
{
$fingerprint = $item?->source_fingerprint;
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
}
/**
* @param array<string, mixed> $entry
*/
private static function hasGovernanceWarning(array $entry): bool
{
if (is_string($entry['governance_warning'] ?? null) && trim((string) $entry['governance_warning']) !== '') {
return true;
}
return in_array((string) ($entry['governance_state'] ?? ''), [
'expired_exception',
'revoked_exception',
'rejected_exception',
'risk_accepted_without_valid_exception',
], true);
}
public static function readinessLabel(string $bucket): string
{
return match ($bucket) {
'follow_up_required' => 'Follow-up required',
'review_recommended' => 'Review recommended',
'evidence_on_record' => 'Evidence on record',
default => Str::headline($bucket),
};
}
public static function limitationLabel(string $flag): string
{
return match ($flag) {
'accepted_risk_influenced' => 'Accepted risk influences this view',
'partial_mapping' => 'Partial evidence mapping',
'stale_evidence' => 'Evidence freshness needs review',
'supporting_evidence_unavailable' => 'Supporting evidence unavailable',
'unmapped' => 'No mapped control coverage',
default => Str::headline($flag),
};
}
}

View File

@ -141,7 +141,7 @@ public function supportsFilters(string $domainKey, string $subjectClass): bool
public function groupLabel(string $domainKey, string $subjectClass): string
{
return match ([trim($domainKey), trim($subjectClass)]) {
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => __('localization.policy.taxonomy.policies'),
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => 'Intune policies',
[GovernanceDomainKey::PlatformFoundation->value, GovernanceSubjectClass::ConfigurationResource->value] => 'Platform foundation configuration resources',
[GovernanceDomainKey::Entra->value, GovernanceSubjectClass::Control->value] => 'Entra controls',
default => trim($domainKey).' / '.trim($subjectClass),

View File

@ -49,18 +49,6 @@ public function entryLabel(string $relationKey): string
return OperationRunLinks::singularLabel();
}
if ($relationKey === 'current_policy_version') {
return __('localization.policy.versions.related_entry_current_policy_version');
}
if ($relationKey === 'parent_policy') {
return __('localization.policy.versions.related_entry_policy');
}
if ($relationKey === 'policy_version') {
return __('localization.policy.versions.related_entry_policy_version');
}
return self::ENTRY_LABELS[$relationKey] ?? Str::headline(str_replace('_', ' ', $relationKey));
}
@ -74,14 +62,6 @@ public function actionLabel(string $relationKey): string
return OperationRunLinks::openLabel();
}
if ($relationKey === 'parent_policy') {
return __('localization.policy.versions.related_action_view_policy');
}
if (in_array($relationKey, ['current_policy_version', 'policy_version'], true)) {
return __('localization.policy.versions.related_action_view_policy_version');
}
return self::ACTION_LABELS[$relationKey] ?? 'Open '.Str::headline(str_replace('_', ' ', $relationKey));
}

View File

@ -54,12 +54,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
return $this->resolved(
descriptor: $descriptor,
primaryLabel: (string) ($policy->display_name ?: __('localization.policy.versions.related_entry_policy')),
secondaryLabel: __('localization.policy.versions.reference_policy_number', ['id' => $policy->getKey()]),
primaryLabel: (string) ($policy->display_name ?: 'Policy'),
secondaryLabel: 'Policy #'.$policy->getKey(),
linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::Policy->value,
url: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant),
actionLabel: __('localization.policy.versions.related_action_view_policy'),
actionLabel: 'View policy',
contextBadge: 'Tenant',
),
);

View File

@ -53,7 +53,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
}
$policyName = $version->policy?->display_name;
$secondary = __('localization.policy.versions.reference_version_number', ['version' => (string) $version->version_number]);
$secondary = 'Version '.(string) $version->version_number;
if (is_string($version->capture_purpose?->value) && $version->capture_purpose->value !== '') {
$secondary .= ' · '.str_replace('_', ' ', $version->capture_purpose->value);
@ -61,12 +61,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
return $this->resolved(
descriptor: $descriptor,
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : __('localization.policy.versions.related_entry_policy_version'),
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : 'Policy version',
secondaryLabel: $secondary,
linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::PolicyVersion->value,
url: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant),
actionLabel: __('localization.policy.versions.related_action_view_policy_version'),
actionLabel: 'View policy version',
contextBadge: 'Tenant',
),
);

View File

@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('policies') || Schema::hasColumn('policies', 'missing_from_provider_at')) {
return;
}
Schema::table('policies', function (Blueprint $table): void {
$table->timestamp('missing_from_provider_at')->nullable()->after('ignored_at');
$table->index('missing_from_provider_at');
});
}
public function down(): void
{
if (! Schema::hasTable('policies') || ! Schema::hasColumn('policies', 'missing_from_provider_at')) {
return;
}
Schema::table('policies', function (Blueprint $table): void {
$table->dropIndex(['missing_from_provider_at']);
$table->dropColumn('missing_from_provider_at');
});
}
};

View File

@ -129,64 +129,22 @@
'reporting' => 'Berichte',
'customer_reviews' => 'Kundenreviews',
'customer_review_workspace' => 'Kundenreview-Workspace',
'customer_safe_review_workspace' => 'Kundensicherer Governance-Paket-Index',
'customer_workspace_intro' => 'Prüfen Sie für jeden berechtigten Tenant den aktuellen Status des Governance-Pakets und öffnen Sie bei Bedarf die kundensichere Detailansicht.',
'customer_workspace_canonical_note' => 'Jede Zeile ist ein Einstieg in die Detailansicht: Dort sehen Sie Paketstatus, Nachweise, aktuelle Risiken und den nächsten kundensicheren Schritt.',
'customer_workspace_mapping_version' => 'Die Control-Readiness-Interpretation verwendet :version für diesen Workspace.',
'customer_workspace_non_certification_disclosure' => 'Dieser Workspace fasst die aktuelle Review- und Nachweislage für die Service-Auslieferung zusammen. Er ersetzt weder ein formales Auditurteil noch eine Zertifizierung oder rechtliche Attestierung.',
'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace',
'customer_workspace_intro' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.',
'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.',
'reviews' => 'Reviews',
'clear_filters' => 'Filter löschen',
'tenant' => 'Tenant',
'latest_review' => 'Letztes Review',
'review_status' => 'Review-Status',
'status' => 'Status',
'control' => 'Control',
'control_interpretation' => 'Control-Readiness-Interpretation',
'control_readiness' => 'Control-Readiness',
'assessment_status' => 'Prüfstatus',
'review_recommended' => 'Review empfohlen',
'recommended_next_action' => 'Empfohlene nächste Aktion',
'customer_safe' => 'Kundensicher',
'interpretation_version_short' => 'Interpretationsversion: :version',
'additional_controls' => '+:count weitere Control(s)',
'control_limitations_summary' => 'Limitierungen: :limitations.',
'control_readiness_unmapped' => 'Keine gemappten Controls',
'control_readiness_unmapped_description' => 'In diesem veröffentlichten Review sind keine kanonischen Controls gemappt. Behandeln Sie die Control-Sicht als partiell, bis Evidence-Referenzen gemappt werden können.',
'control_evidence_unmapped' => 'Keine gemappte Evidence-Basis verfügbar.',
'control_evidence_unavailable' => 'Evidence-Basis nicht verfügbar.',
'control_recommendation_unmapped' => 'Prüfen Sie unmapped Evidence vor der Kundenauslieferung.',
'proof_access_state' => 'Proof-Zugriff',
'key_findings' => 'Wichtige Findings',
'accepted_risks' => 'Akzeptierte Risiken',
'evidence_proof' => 'Evidence-Nachweis',
'evidence_status' => 'Nachweise',
'published' => 'Veröffentlicht',
'review_pack' => 'Review-Pack',
'open_latest_review' => 'Letztes Review öffnen',
'open' => 'Öffnen',
'open_review' => 'Review öffnen',
'last_review' => 'Letztes Review',
'primary_action' => 'Primäre Aktion',
'download_review_pack' => 'Review-Pack herunterladen',
'download_current_review_pack' => 'Aktuelles Review-Pack herunterladen',
'download_governance_package' => 'Governance-Paket herunterladen',
'governance_package' => 'Governance-Paket',
'governance_decisions' => 'Governance-Entscheidungen',
'governance_package_delivery_note' => 'Dieses Governance-Paket wird über das aktuelle Export-Review-Pack des veröffentlichten Reviews ausgeliefert.',
'governance_package_available' => 'Governance-Paket verfügbar',
'governance_package_available_description' => 'Das aktuelle Export-Review-Pack ist aus diesem veröffentlichten Review für die Stakeholder-Auslieferung bereit.',
'governance_package_partial' => 'Governance-Paket partiell',
'governance_package_partial_description' => 'Das aktuelle Export-Review-Pack ist bereit, aber die zugrunde liegende Review-Basis bleibt partiell oder limitierungsbehaftet.',
'governance_package_unavailable' => 'Governance-Paket nicht verfügbar',
'governance_package_unavailable_description' => 'Diesem veröffentlichten Review ist noch kein aktuelles Export-Review-Pack zugeordnet.',
'governance_package_not_ready_description' => 'Das aktuelle Export-Review-Pack ist für die Stakeholder-Auslieferung noch nicht bereit.',
'governance_package_expired' => 'Governance-Paket abgelaufen',
'governance_package_expired_description' => 'Das aktuelle Export-Review-Pack ist abgelaufen und kann aus diesem veröffentlichten Review nicht heruntergeladen werden.',
'governance_package_blocked' => 'Governance-Paket blockiert',
'governance_package_blocked_description' => 'Dieses Konto kann das veröffentlichte Review lesen, aber das aktuelle Export-Review-Pack nicht herunterladen.',
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
'no_released_customer_reviews' => 'Keine veröffentlichten Kundenreviews passen zu dieser Ansicht',
'no_released_customer_reviews_description' => 'Veröffentlichen Sie ein Tenant-Review, bevor es im kundensicheren Workspace erscheint.',
'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
'no_published_review' => 'Kein veröffentlichtes Review',
@ -204,10 +162,6 @@
'accepted_risk_partial_accountability' => 'Die Verantwortlichkeit ist teilweise erfasst; Review-Owner-Details sind nicht vollständig verfügbar.',
'unavailable' => 'Nicht verfügbar',
'available' => 'Verfügbar',
'partial' => 'Teilweise',
'blocked' => 'Blockiert',
'expired' => 'Abgelaufen',
'restricted' => 'Eingeschränkt',
'review_pack_available' => 'Aktuelles Review-Pack verfügbar',
'no_current_review_pack' => 'Noch kein aktuelles Review-Pack verfügbar',
'review_pack_access_unavailable' => 'Review-Pack-Zugriff ist für dieses Konto nicht verfügbar',
@ -217,19 +171,6 @@
'evidence_proof_absent' => 'Noch keine Nachweiszusammenfassung verknüpft',
'evidence_proof_access_unavailable' => 'Nachweiszugriff ist für dieses Konto nicht verfügbar',
'evidence_proof_expired' => 'Nachweiszusammenfassung abgelaufen',
'evidence_available' => 'Nachweise verfügbar',
'evidence_pending' => 'Nachweise ausstehend',
'evidence_restricted' => 'Nachweise eingeschränkt',
'evidence_expired' => 'Nachweise abgelaufen',
'assessment_basis' => 'Prüfgrundlage',
'assessment_basis_description' => 'Diese Prüfbereiche zeigen, wie die Aussagen des Pakets durch die aktuelle Review-Evidenz gestützt werden.',
'review_completed' => 'Review abgeschlossen',
'review_requires_attention' => 'Prüfung erforderlich',
'ready_for_release' => 'Zur Veröffentlichung bereit',
'accepted_risk_status' => 'Status akzeptierter Risiken',
'accepted_risk_none' => 'Keine erfasst',
'accepted_risk_on_record' => ':count erfasst',
'accepted_risk_follow_up' => 'Nacharbeit erforderlich',
'customer_review_pack_unavailable' => 'Das aktuelle Review-Pack kann aus diesem kundensicheren Flow nicht heruntergeladen werden.',
'customer_review_pack_missing' => 'Diesem veröffentlichten Review ist noch kein aktuelles Review-Pack zugeordnet.',
'customer_review_pack_not_ready' => 'Das zugeordnete Review-Pack ist noch nicht für den Download bereit.',
@ -250,10 +191,6 @@
'outcome' => 'Ergebnis',
'export' => 'Export',
'next_step' => 'Nächster Schritt',
'workspace_next_step_evidence_review' => 'Nachweise prüfen',
'workspace_next_step_review_open' => 'Review öffnen',
'workspace_next_step_package_review' => 'Paket prüfen',
'workspace_next_step_control_mapping' => 'Kontrollzuordnung prüfen',
'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews',
'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie für diesen Tenant zu starten.',
'create_first_review' => 'Erstes Review erstellen',
@ -325,188 +262,6 @@
'actions' => 'Aktionen',
'open_approval_queue' => 'Freigabewarteschlange öffnen',
],
'policy' => [
'common' => [
'policy' => 'Richtlinie',
'policies' => 'Richtlinien',
'type' => 'Typ',
'visibility' => 'Sichtbarkeit',
'category' => 'Kategorie',
'restore' => 'Wiederherstellen',
'platform' => 'Plattform',
'settings' => 'Einstellungen',
'external_id' => 'Externe ID',
'last_synced' => 'Zuletzt synchronisiert',
'snapshot' => 'Snapshot',
'version' => 'Version',
'actor' => 'Akteur',
'created' => 'Erstellt',
'captured' => 'Erfasst',
'platform_label_windows' => 'Windows',
'platform_label_android' => 'Android',
'platform_label_ios' => 'iOS',
'platform_label_macos' => 'macOS',
'platform_label_all' => 'Alle',
'platform_label_mobile' => 'Mobil',
'open_operation' => 'Operation öffnen',
'more' => 'Mehr',
'backup_name' => 'Backup-Name',
'backup_name_default_prefix' => 'Backup',
'source_microsoft_intune' => 'Quelle: Microsoft Intune',
'type_delete_to_confirm' => 'Zur Bestätigung DELETE eingeben',
'type_delete_to_confirm_validation' => 'Bitte DELETE zur Bestätigung eingeben.',
'preview_only_dry_run' => 'Nur Vorschau (Dry-Run)',
],
'resource' => [
'sync_action_primary' => 'Richtlinien synchronisieren',
'sync_action_secondary' => 'Synchronisieren',
'sync_modal_heading' => 'Richtlinien-Inventar synchronisieren',
'sync_modal_description' => 'Diese Aktion reiht eine Hintergrundssynchronisierung für unterstützte Richtlinientypen im aktuellen Tenant ein.',
'sync_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu synchronisieren.',
'capture_snapshot_action' => 'Snapshot erfassen',
'capture_snapshot_modal_heading' => 'Snapshot jetzt erfassen',
'capture_snapshot_modal_subheading' => 'Diese Aktion reiht einen Hintergrundjob ein, der die aktuelle Konfiguration aus Microsoft Graph abruft und eine neue Richtlinienversion speichert.',
'capture_snapshot_include_assignments' => 'Zuweisungen einschließen',
'capture_snapshot_include_assignments_helper' => 'Erfasst Include-/Exclude-Ziele und Filter für Zuweisungen.',
'capture_snapshot_include_scope_tags' => 'Scope-Tags einschließen',
'capture_snapshot_include_scope_tags_helper' => 'Erfasst die Scope-Tag-IDs der Richtlinie.',
'capture_snapshot_unavailable_title' => 'Snapshot-Erfassung nicht verfügbar',
'capture_snapshot_in_progress_title' => 'Snapshot bereits in Arbeit',
'capture_snapshot_in_progress_body' => 'Für diese Richtlinie existiert bereits ein aktiver Lauf. Laufdetails werden geöffnet.',
'capture_snapshot_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien-Snapshots zu erfassen.',
'visibility_source_unavailable_description' => 'Die verbundene Quelle hat diese Richtlinie nicht geliefert oder ist aktuell nicht verfügbar. Die historische Wiederherstellung bleibt verfügbar.',
'visibility_source_unavailable_backup_items' => 'Die verbundene Quelle hat diese Richtlinie nicht geliefert oder ist aktuell nicht verfügbar. Historische Backup-Items bleiben für die Wiederherstellungsauswahl verfügbar.',
'details_section' => 'Richtliniendetails',
'tab_general' => 'Allgemein',
'tab_json' => 'JSON',
'general_field_name' => 'Name',
'general_field_platforms' => 'Plattformen',
'general_field_technologies' => 'Technologien',
'general_field_template_reference' => 'Vorlagenreferenz',
'general_field_setting_count' => 'Anzahl Einstellungen',
'general_field_version' => 'Version',
'general_field_last_modified' => 'Zuletzt geändert',
'general_field_created' => 'Erstellt',
'general_field_description' => 'Beschreibung',
'general_empty_state' => 'Keine allgemeinen Metadaten verfügbar.',
'general_fallback_field' => 'Feld',
'template_fallback' => 'Vorlage',
'settings_empty_state' => 'Noch kein Richtlinien-Snapshot verfügbar.',
'settings_empty_state_helper' => 'Diese Richtlinie wurde inventarisiert, aber es wurde noch kein Konfigurations-Snapshot erfasst.',
'snapshot_metadata_only_helper' => 'Graph lieferte für diesen Richtlinientyp :status zurück. Es wurden nur lokale Metadaten gespeichert; Einstellungen und Wiederherstellung sind erst verfügbar, wenn Graph wieder erfolgreich antwortet.',
'graph_error_fallback' => 'einen Fehler',
'snapshot_json_section' => 'Richtlinien-Snapshot (JSON)',
'payload_size' => 'Payload-Größe',
'large_payload_warning' => 'Großer Payload (:size KB) - kann die Performance beeinträchtigen',
'settings_available' => 'Verfügbar',
'settings_missing' => 'Fehlt',
'filter_active' => 'Aktiv',
'filter_ignored' => 'Lokal ignoriert',
'filter_source_unavailable' => 'Quelle nicht verfügbar',
'filter_all' => 'Alle',
'export_to_backup' => 'Ins Backup exportieren',
'current_backup_unavailable' => 'Aktuelles Backup nicht verfügbar',
'restore_action' => 'Wiederherstellen',
'restore_bulk_action' => 'Richtlinien wiederherstellen',
'restore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien wiederherzustellen.',
'policy_restored' => 'Richtlinie wiederhergestellt',
'ignore_action' => 'Ignorieren',
'ignore_bulk_action' => 'Richtlinien ignorieren',
'ignore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu ignorieren.',
'policy_ignored' => 'Richtlinie ignoriert',
'empty_state_heading' => 'Noch keine Richtlinien im Inventory',
'empty_state_description' => 'Starte eine Synchronisierung, um das Richtlinien-Inventar dieses Tenants mit Versionen, Wiederherstellbarkeit und Governance-Evidence aufzubauen.',
'delete_queued_body' => 'Löschung für :count Richtlinien eingeplant.',
],
'versions' => [
'backup_quality_section' => 'Backup-Qualität',
'related_context_section' => 'Zugehöriger Kontext',
'diff_tab' => 'Diff',
'backup_quality' => 'Backup-Qualität',
'snapshot_mode_full' => 'Vollständig',
'snapshot_mode_metadata_only' => 'Nur Metadaten',
'assignment_quality' => 'Zuweisungsqualität',
'next_action' => 'Nächste Aktion',
'integrity_note' => 'Integritätshinweis',
'boundary' => 'Abgrenzung',
'quality_highlight_metadata_only' => 'Nur Metadaten',
'quality_highlight_assignment_fetch_failed' => 'Abruf der Zuweisungen fehlgeschlagen',
'quality_highlight_assignments_captured_separately' => 'Zuweisungen separat erfasst',
'quality_highlight_orphaned_assignments' => 'Verwaiste Zuweisungen erkannt',
'quality_highlight_integrity_warning' => 'Integritätswarnung',
'quality_highlight_unknown_quality' => 'Unbekannte Qualität',
'compact_summary_full_payload' => 'Vollständige Nutzlast',
'compact_summary_unknown_quality' => 'Unbekannte Qualität',
'compact_summary_no_degradations_detected' => 'Keine Degradationen erkannt',
'summary_full_no_degradations' => 'In Snapshot und Zuweisungsmetadaten wurden keine Degradationen erkannt.',
'summary_unknown_quality' => 'Die Qualität ist unbekannt, weil diesem Datensatz ausreichende Vollständigkeitsmetadaten für eine stärkere Aussage fehlen.',
'summary_no_degradations' => 'Es wurden keine Degradationen erkannt.',
'next_action_open_version_detail' => 'Öffne die Versionsdetails, wenn du Roh-Einstellungen oder Diff-Kontext brauchst.',
'next_action_prefer_stronger_version' => 'Bevorzuge eine stärkere Version oder prüfe die Versionsdetails vor der Wiederherstellung.',
'raw_diff_advanced' => 'Rohdiff (erweitert)',
'prune_versions' => 'Versionen bereinigen',
'prune_modal_description' => 'Nur Versionen, die älter als das angegebene Aufbewahrungsfenster in Tagen sind, kommen infrage. Neuere Versionen werden übersprungen.',
'retention_days' => 'Aufbewahrungstage',
'retention_days_helper' => 'Versionen aus den letzten N Tagen werden übersprungen.',
'manage_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinienversionen zu verwalten.',
'restore_versions' => 'Versionen wiederherstellen',
'restore_versions_modal_heading' => ':count Richtlinienversionen wiederherstellen?',
'restore_versions_modal_description' => 'Archivierte Versionen werden in die aktive Liste zurückgeführt. Aktive Versionen werden übersprungen.',
'force_delete_versions' => 'Versionen endgültig löschen',
'force_delete_versions_modal_heading' => ':count Richtlinienversionen endgültig löschen?',
'force_delete_versions_modal_description' => 'Dies ist endgültig. Nur archivierte Versionen werden dauerhaft gelöscht; aktive Versionen werden übersprungen.',
'restore_via_wizard' => 'Über Assistent wiederherstellen',
'restore_via_wizard_modal_heading' => 'Version :version über Assistent wiederherstellen?',
'restore_via_wizard_modal_subheading' => 'Erstellt aus diesem Snapshot ein Backup-Set mit einem Element und öffnet den Wiederherstellungsassistenten vorausgefüllt.',
'restore_run_permission_tooltip' => 'Sie haben keine Berechtigung, Wiederherstellungsläufe zu erstellen.',
'metadata_only_tooltip' => 'Für reine Metadaten-Snapshots deaktiviert (Graph hat keine Richtlinieneinstellungen geliefert).',
'restore_disabled_metadata_title' => 'Wiederherstellung für reinen Metadaten-Snapshot deaktiviert',
'restore_disabled_metadata_body' => 'Dieser Snapshot enthält nur Metadaten; Graph hat keine Richtlinieneinstellungen für eine Wiederherstellung geliefert.',
'different_tenant_title' => 'Richtlinienversion gehört zu einem anderen Tenant',
'missing_policy_title' => 'Richtlinie für diese Version konnte nicht gefunden werden',
'backup_set_name' => 'Richtlinienversions-Wiederherstellung - :policy - v:version',
'archive' => 'Archivieren',
'archived_title' => 'Richtlinienversion archiviert',
'force_delete' => 'Endgültig löschen',
'force_deleted_title' => 'Richtlinienversion dauerhaft gelöscht',
'restored_title' => 'Richtlinienversion wiederhergestellt',
'empty_state_heading' => 'Noch keine Richtlinienversionen',
'empty_state_description' => 'Erfasse oder synchronisiere Richtlinien-Snapshots, um eine Versionshistorie aufzubauen.',
'open_backup_sets' => 'Backup-Sets öffnen',
'related_entry_current_policy_version' => 'Aktuelle Richtlinienversion',
'related_entry_policy' => 'Richtlinie',
'related_entry_policy_version' => 'Richtlinienversion',
'related_action_view_policy' => 'Richtlinie anzeigen',
'related_action_view_policy_version' => 'Richtlinienversion anzeigen',
'reference_policy_number' => 'Richtlinie #:id',
'reference_version_number' => 'Version :version',
'related_record_fallback' => 'Zugehörigen Datensatz öffnen',
'assignment_fetch_failed_orphaned' => 'Das Abrufen der Zuweisungen ist fehlgeschlagen und verwaiste Ziele wurden erkannt.',
'assignment_fetch_failed' => 'Das Abrufen der Zuweisungen ist während der Erfassung fehlgeschlagen.',
'assignment_orphaned' => 'Verwaiste Zuweisungsziele wurden erkannt.',
'assignment_no_issues' => 'Aus den erfassten Metadaten wurden keine Zuweisungsprobleme erkannt.',
'fallback_display_name' => 'Version :version',
],
'relation' => [
'restore_to_microsoft_intune' => 'In Microsoft Intune wiederherstellen',
'restore_heading' => 'Version :version in Microsoft Intune wiederherstellen?',
'restore_subheading' => 'Erstellt einen Wiederherstellungslauf mit diesem Richtlinienversions-Snapshot.',
'missing_context_title' => 'Tenant- oder Benutzerkontext fehlt.',
'restore_run_failed_title' => 'Wiederherstellungslauf konnte nicht gestartet werden',
'restore_run_started_title' => 'Wiederherstellungslauf gestartet',
'no_versions_captured' => 'Noch keine Versionen erfasst',
'no_versions_captured_description' => 'Erfasse oder synchronisiere diese Richtlinie erneut, um Versionshistorieneinträge zu erzeugen.',
],
'badges' => [
'active' => 'Aktiv',
'ignored_locally' => 'Lokal ignoriert',
'source_unavailable' => 'Quelle nicht verfügbar',
'ignored_source_unavailable' => 'Ignoriert + Quelle nicht verfügbar',
],
'taxonomy' => [
'policies' => 'Richtlinien',
],
],
'notifications' => [
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',

View File

@ -129,64 +129,22 @@
'reporting' => 'Reporting',
'customer_reviews' => 'Customer reviews',
'customer_review_workspace' => 'Customer Review Workspace',
'customer_safe_review_workspace' => 'Customer-safe governance package index',
'customer_workspace_intro' => 'Review the current governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.',
'customer_workspace_canonical_note' => 'Each row is an index entry: open the review detail to inspect package status, supporting evidence, current risks, and the next customer-safe action.',
'customer_workspace_mapping_version' => 'Control readiness interpretation uses :version for this workspace.',
'customer_workspace_non_certification_disclosure' => 'This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.',
'customer_safe_review_workspace' => 'Customer-safe review workspace',
'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.',
'customer_workspace_canonical_note' => 'Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.',
'reviews' => 'Reviews',
'clear_filters' => 'Clear filters',
'tenant' => 'Tenant',
'latest_review' => 'Latest review',
'review_status' => 'Review status',
'status' => 'Status',
'control' => 'Control',
'control_interpretation' => 'Control readiness interpretation',
'control_readiness' => 'Control readiness',
'assessment_status' => 'Assessment status',
'review_recommended' => 'Review recommended',
'recommended_next_action' => 'Recommended next action',
'customer_safe' => 'Customer-safe',
'interpretation_version_short' => 'Interpretation version: :version',
'additional_controls' => '+:count more control(s)',
'control_limitations_summary' => 'Limitations: :limitations.',
'control_readiness_unmapped' => 'No mapped controls',
'control_readiness_unmapped_description' => 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.',
'control_evidence_unmapped' => 'No mapped evidence basis is available.',
'control_evidence_unavailable' => 'Evidence basis unavailable.',
'control_recommendation_unmapped' => 'Review unmapped evidence before customer delivery.',
'proof_access_state' => 'Proof access',
'key_findings' => 'Key findings',
'accepted_risks' => 'Accepted risks',
'evidence_proof' => 'Evidence proof',
'evidence_status' => 'Evidence',
'published' => 'Published',
'review_pack' => 'Review pack',
'open_latest_review' => 'Open latest review',
'open' => 'Open',
'open_review' => 'Open review',
'last_review' => 'Last review',
'primary_action' => 'Primary action',
'download_review_pack' => 'Download review pack',
'download_current_review_pack' => 'Download current review pack',
'download_governance_package' => 'Download governance package',
'governance_package' => 'Governance package',
'governance_decisions' => 'Governance decisions',
'governance_package_delivery_note' => 'This governance package is delivered through the current export review pack for the released review.',
'governance_package_available' => 'Governance package available',
'governance_package_available_description' => 'The current export review pack is ready for stakeholder delivery from this released review.',
'governance_package_partial' => 'Governance package partial',
'governance_package_partial_description' => 'The current export review pack is ready, but the supporting review basis remains partial or limitation-aware.',
'governance_package_unavailable' => 'Governance package unavailable',
'governance_package_unavailable_description' => 'No current export review pack is attached to this released review yet.',
'governance_package_not_ready_description' => 'The current export review pack is not ready for stakeholder delivery yet.',
'governance_package_expired' => 'Governance package expired',
'governance_package_expired_description' => 'The current export review pack has expired and cannot be downloaded from this released review.',
'governance_package_blocked' => 'Governance package blocked',
'governance_package_blocked_description' => 'This account can read the released review but cannot download the current export review pack.',
'no_entitled_tenants' => 'No entitled tenants match this view',
'no_released_customer_reviews' => 'No released customer reviews match this view',
'no_released_customer_reviews_description' => 'Publish a tenant review before it appears in the customer-safe workspace.',
'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.',
'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.',
'no_published_review' => 'No published review',
@ -204,10 +162,6 @@
'accepted_risk_partial_accountability' => 'Accountability is partially recorded; review owner details are not fully available.',
'unavailable' => 'Unavailable',
'available' => 'Available',
'partial' => 'Partial',
'blocked' => 'Blocked',
'expired' => 'Expired',
'restricted' => 'Restricted',
'review_pack_available' => 'Current review pack available',
'no_current_review_pack' => 'No current review pack available yet',
'review_pack_access_unavailable' => 'Review pack access is unavailable for this actor',
@ -217,19 +171,6 @@
'evidence_proof_absent' => 'No proof summary linked yet',
'evidence_proof_access_unavailable' => 'Proof access is unavailable for this actor',
'evidence_proof_expired' => 'Proof summary expired',
'evidence_available' => 'Evidence available',
'evidence_pending' => 'Evidence pending',
'evidence_restricted' => 'Evidence restricted',
'evidence_expired' => 'Evidence expired',
'assessment_basis' => 'Assessment basis',
'assessment_basis_description' => 'These assessment areas explain how the package statements are supported by the current review evidence.',
'review_completed' => 'Review completed',
'review_requires_attention' => 'Review required',
'ready_for_release' => 'Ready for release',
'accepted_risk_status' => 'Accepted risk status',
'accepted_risk_none' => 'None on record',
'accepted_risk_on_record' => ':count on record',
'accepted_risk_follow_up' => 'Follow-up required',
'customer_review_pack_unavailable' => 'The current review pack cannot be downloaded from this customer-safe flow.',
'customer_review_pack_missing' => 'No current review pack is attached to this released review yet.',
'customer_review_pack_not_ready' => 'The attached review pack is not ready for download yet.',
@ -250,10 +191,6 @@
'outcome' => 'Outcome',
'export' => 'Export',
'next_step' => 'Next step',
'workspace_next_step_evidence_review' => 'Review evidence',
'workspace_next_step_review_open' => 'Open review',
'workspace_next_step_package_review' => 'Review package',
'workspace_next_step_control_mapping' => 'Review control mapping',
'no_tenant_reviews_yet' => 'No tenant reviews yet',
'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.',
'create_first_review' => 'Create first review',
@ -325,188 +262,6 @@
'actions' => 'Actions',
'open_approval_queue' => 'Open approval queue',
],
'policy' => [
'common' => [
'policy' => 'Policy',
'policies' => 'Policies',
'type' => 'Type',
'visibility' => 'Visibility',
'category' => 'Category',
'restore' => 'Restore',
'platform' => 'Platform',
'settings' => 'Settings',
'external_id' => 'External ID',
'last_synced' => 'Last synced',
'snapshot' => 'Snapshot',
'version' => 'Version',
'actor' => 'Actor',
'created' => 'Created',
'captured' => 'Captured',
'platform_label_windows' => 'Windows',
'platform_label_android' => 'Android',
'platform_label_ios' => 'iOS',
'platform_label_macos' => 'macOS',
'platform_label_all' => 'All',
'platform_label_mobile' => 'Mobile',
'open_operation' => 'Open operation',
'more' => 'More',
'backup_name' => 'Backup name',
'backup_name_default_prefix' => 'Backup',
'source_microsoft_intune' => 'Source: Microsoft Intune',
'type_delete_to_confirm' => 'Type DELETE to confirm',
'type_delete_to_confirm_validation' => 'Please type DELETE to confirm.',
'preview_only_dry_run' => 'Preview only (dry-run)',
],
'resource' => [
'sync_action_primary' => 'Sync policies',
'sync_action_secondary' => 'Sync',
'sync_modal_heading' => 'Sync policy inventory',
'sync_modal_description' => 'This queues a background sync operation for supported policy types in the current tenant.',
'sync_permission_tooltip' => 'You do not have permission to sync policies.',
'capture_snapshot_action' => 'Capture snapshot',
'capture_snapshot_modal_heading' => 'Capture snapshot now',
'capture_snapshot_modal_subheading' => 'This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.',
'capture_snapshot_include_assignments' => 'Include assignments',
'capture_snapshot_include_assignments_helper' => 'Captures assignment include/exclude targeting and filters.',
'capture_snapshot_include_scope_tags' => 'Include scope tags',
'capture_snapshot_include_scope_tags_helper' => 'Captures policy scope tag IDs.',
'capture_snapshot_unavailable_title' => 'Snapshot capture unavailable',
'capture_snapshot_in_progress_title' => 'Snapshot already in progress',
'capture_snapshot_in_progress_body' => 'An active run already exists for this policy. Opening run details.',
'capture_snapshot_permission_tooltip' => 'You do not have permission to capture policy snapshots.',
'visibility_source_unavailable_description' => 'The connected source did not return this policy or is currently unavailable. Historical restore remains available.',
'visibility_source_unavailable_backup_items' => 'The connected source did not return this policy or is currently unavailable. Historical backup items remain available for restore selection.',
'details_section' => 'Policy details',
'tab_general' => 'General',
'tab_json' => 'JSON',
'general_field_name' => 'Name',
'general_field_platforms' => 'Platforms',
'general_field_technologies' => 'Technologies',
'general_field_template_reference' => 'Template reference',
'general_field_setting_count' => 'Setting count',
'general_field_version' => 'Version',
'general_field_last_modified' => 'Last modified',
'general_field_created' => 'Created',
'general_field_description' => 'Description',
'general_empty_state' => 'No general metadata available.',
'general_fallback_field' => 'Field',
'template_fallback' => 'Template',
'settings_empty_state' => 'No policy snapshot available yet.',
'settings_empty_state_helper' => 'This policy has been inventoried but no configuration snapshot has been captured yet.',
'snapshot_metadata_only_helper' => 'Graph returned :status for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
'graph_error_fallback' => 'an error',
'snapshot_json_section' => 'Policy snapshot (JSON)',
'payload_size' => 'Payload size',
'large_payload_warning' => 'Large payload (:size KB) - may impact performance',
'settings_available' => 'Available',
'settings_missing' => 'Missing',
'filter_active' => 'Active',
'filter_ignored' => 'Ignored locally',
'filter_source_unavailable' => 'Source unavailable',
'filter_all' => 'All',
'export_to_backup' => 'Export to backup',
'current_backup_unavailable' => 'Current backup unavailable',
'restore_action' => 'Restore',
'restore_bulk_action' => 'Restore policies',
'restore_permission_tooltip' => 'You do not have permission to restore policies.',
'policy_restored' => 'Policy restored',
'ignore_action' => 'Ignore',
'ignore_bulk_action' => 'Ignore policies',
'ignore_permission_tooltip' => 'You do not have permission to ignore policies.',
'policy_ignored' => 'Policy ignored',
'empty_state_heading' => 'No policies in inventory yet',
'empty_state_description' => 'Run a sync to build this tenant\'s policy inventory, including versions, restore readiness, and governance evidence.',
'delete_queued_body' => 'Queued deletion for :count policies.',
],
'versions' => [
'backup_quality_section' => 'Backup quality',
'related_context_section' => 'Related context',
'diff_tab' => 'Diff',
'backup_quality' => 'Backup quality',
'snapshot_mode_full' => 'Full',
'snapshot_mode_metadata_only' => 'Metadata only',
'assignment_quality' => 'Assignment quality',
'next_action' => 'Next action',
'integrity_note' => 'Integrity note',
'boundary' => 'Boundary',
'quality_highlight_metadata_only' => 'Metadata only',
'quality_highlight_assignment_fetch_failed' => 'Assignment fetch failed',
'quality_highlight_assignments_captured_separately' => 'Assignments captured separately',
'quality_highlight_orphaned_assignments' => 'Orphaned assignments',
'quality_highlight_integrity_warning' => 'Integrity warning',
'quality_highlight_unknown_quality' => 'Unknown quality',
'compact_summary_full_payload' => 'Full payload',
'compact_summary_unknown_quality' => 'Unknown quality',
'compact_summary_no_degradations_detected' => 'No degradations detected',
'summary_full_no_degradations' => 'No degradations were detected from the captured snapshot and assignment metadata.',
'summary_unknown_quality' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
'summary_no_degradations' => 'No degradations were detected.',
'next_action_open_version_detail' => 'Open the version detail if you need raw settings or diff context.',
'next_action_prefer_stronger_version' => 'Prefer a stronger version or inspect the version detail before restore.',
'raw_diff_advanced' => 'Raw diff (advanced)',
'prune_versions' => 'Prune versions',
'prune_modal_description' => 'Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.',
'retention_days' => 'Retention days',
'retention_days_helper' => 'Versions captured within the last N days will be skipped.',
'manage_permission_tooltip' => 'You do not have permission to manage policy versions.',
'restore_versions' => 'Restore versions',
'restore_versions_modal_heading' => 'Restore :count policy versions?',
'restore_versions_modal_description' => 'Archived versions will be restored back to the active list. Active versions will be skipped.',
'force_delete_versions' => 'Force delete versions',
'force_delete_versions_modal_heading' => 'Force delete :count policy versions?',
'force_delete_versions_modal_description' => 'This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.',
'restore_via_wizard' => 'Restore via wizard',
'restore_via_wizard_modal_heading' => 'Restore version :version via wizard?',
'restore_via_wizard_modal_subheading' => 'Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.',
'restore_run_permission_tooltip' => 'You do not have permission to create restore runs.',
'metadata_only_tooltip' => 'Disabled for metadata-only snapshots (Graph did not provide policy settings).',
'restore_disabled_metadata_title' => 'Restore disabled for metadata-only snapshot',
'restore_disabled_metadata_body' => 'This snapshot only contains metadata; Graph did not provide policy settings to restore.',
'different_tenant_title' => 'Policy version belongs to a different tenant',
'missing_policy_title' => 'Policy could not be found for this version',
'backup_set_name' => 'Policy version restore - :policy - v:version',
'archive' => 'Archive',
'archived_title' => 'Policy version archived',
'force_delete' => 'Force delete',
'force_deleted_title' => 'Policy version permanently deleted',
'restored_title' => 'Policy version restored',
'empty_state_heading' => 'No policy versions',
'empty_state_description' => 'Capture or sync policy snapshots to build a version history.',
'open_backup_sets' => 'Open backup sets',
'related_entry_current_policy_version' => 'Current policy version',
'related_entry_policy' => 'Policy',
'related_entry_policy_version' => 'Policy version',
'related_action_view_policy' => 'View policy',
'related_action_view_policy_version' => 'View policy version',
'reference_policy_number' => 'Policy #:id',
'reference_version_number' => 'Version :version',
'related_record_fallback' => 'Open related record',
'assignment_fetch_failed_orphaned' => 'Assignment fetch failed and orphaned targets were detected.',
'assignment_fetch_failed' => 'Assignment fetch failed during capture.',
'assignment_orphaned' => 'Orphaned assignment targets were detected.',
'assignment_no_issues' => 'No assignment issues were detected from captured metadata.',
'fallback_display_name' => 'Version :version',
],
'relation' => [
'restore_to_microsoft_intune' => 'Restore to Microsoft Intune',
'restore_heading' => 'Restore version :version to Microsoft Intune?',
'restore_subheading' => 'Creates a restore run using this policy version snapshot.',
'missing_context_title' => 'Missing tenant or user context.',
'restore_run_failed_title' => 'Restore run failed to start',
'restore_run_started_title' => 'Restore run started',
'no_versions_captured' => 'No versions captured',
'no_versions_captured_description' => 'Capture or sync this policy again to create version history entries.',
],
'badges' => [
'active' => 'Active',
'ignored_locally' => 'Ignored locally',
'source_unavailable' => 'Source unavailable',
'ignored_source_unavailable' => 'Ignored + source unavailable',
],
'taxonomy' => [
'policies' => 'Policies',
],
],
'notifications' => [
'locale_override_saved' => 'Language override applied.',
'locale_override_cleared' => 'Language override cleared.',

View File

@ -41,7 +41,7 @@
continue;
}
$label = is_string($key) && $key !== '' ? $key : __('localization.policy.resource.general_fallback_field');
$label = is_string($key) && $key !== '' ? $key : 'Field';
$cards[] = [
'key' => $label,
@ -92,23 +92,23 @@
@endphp
@if (empty($cards))
<p class="text-sm text-gray-600 dark:text-gray-400">{{ __('localization.policy.resource.general_empty_state') }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">No general metadata available.</p>
@else
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach ($cards as $entry)
@php
$keyLower = $entry['key_lower'] ?? '';
$value = $entry['value'] ?? null;
$isPlatform = str_contains($keyLower, 'platform') || str_contains($keyLower, 'plattform');
$isPlatform = str_contains($keyLower, 'platform');
$isTechnologies = str_contains($keyLower, 'technolog');
$isTemplateReference = str_contains($keyLower, 'template') || str_contains($keyLower, 'vorlage');
$isTemplateReference = str_contains($keyLower, 'template');
$isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null;
$toneKey = match (true) {
str_contains($keyLower, 'name') => 'name',
str_contains($keyLower, 'platform') || str_contains($keyLower, 'plattform') => 'platform',
str_contains($keyLower, 'setting') || str_contains($keyLower, 'einstellung') => 'settings',
str_contains($keyLower, 'template') || str_contains($keyLower, 'vorlage') => 'template',
str_contains($keyLower, 'technology') || str_contains($keyLower, 'technolog') => 'technology',
str_contains($keyLower, 'platform') => 'platform',
str_contains($keyLower, 'setting') => 'settings',
str_contains($keyLower, 'template') => 'template',
str_contains($keyLower, 'technology') => 'technology',
default => 'default',
};
$tone = $toneMap[$toneKey] ?? $toneMap['default'];
@ -152,7 +152,7 @@
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : __('localization.policy.resource.template_fallback') }}
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }}
</div>
<div class="flex flex-wrap gap-2">

View File

@ -9,7 +9,6 @@
$links = is_array($state['links'] ?? null) ? $state['links'] : [];
$disclosure = is_string($state['disclosure'] ?? null) ? $state['disclosure'] : null;
$emptyState = is_string($state['empty_state'] ?? null) ? $state['empty_state'] : null;
$isControlInterpretation = (bool) ($state['is_control_interpretation'] ?? false);
@endphp
<div class="space-y-3">
@ -49,88 +48,21 @@
@continue(! is_array($entry))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
@if ($isControlInterpretation)
@php
$readinessBucket = is_string($entry['readiness_bucket'] ?? null) ? $entry['readiness_bucket'] : 'review_recommended';
$readinessColor = match ($readinessBucket) {
'follow_up_required' => 'warning',
'review_recommended' => 'info',
'evidence_on_record' => 'success',
default => 'gray',
};
$limitationLabels = is_array($entry['limitation_labels'] ?? null) ? $entry['limitation_labels'] : [];
$basisItems = is_array($entry['evidence_basis_items'] ?? null) ? $entry['evidence_basis_items'] : [];
@endphp
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $entry['control_name'] ?? $entry['title'] ?? __('localization.review.control') }}
</div>
@php
$detailParts = collect([
$entry['severity'] ?? null,
$entry['status'] ?? null,
$entry['governance_state'] ?? null,
$entry['outcome'] ?? null,
])->filter(fn ($value) => is_string($value) && trim($value) !== '')->map(fn (string $value) => \Illuminate\Support\Str::headline($value))->all();
@endphp
<x-filament::badge :color="$readinessColor" size="sm">
{{ $entry['readiness_label'] ?? __('localization.review.review_recommended') }}
</x-filament::badge>
</div>
@if (filled($entry['explanation_text'] ?? null))
<div class="mt-2 text-gray-700 dark:text-gray-300">
{{ $entry['explanation_text'] }}
</div>
@endif
@if ($basisItems !== [])
<div class="mt-3 space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.evidence_basis') }}</div>
<ul class="space-y-1 text-gray-700 dark:text-gray-300">
@foreach ($basisItems as $basisItem)
@continue(! is_string($basisItem) || trim($basisItem) === '')
<li>{{ $basisItem }}</li>
@endforeach
</ul>
</div>
@endif
@if (filled($entry['recommended_next_action'] ?? null))
<div class="mt-3 rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
{{ $entry['recommended_next_action'] }}
</div>
@endif
@if ($limitationLabels !== [])
<div class="mt-3 flex flex-wrap gap-1">
@foreach ($limitationLabels as $label)
@continue(! is_string($label) || trim($label) === '')
<x-filament::badge color="gray" size="sm">
{{ $label }}
</x-filament::badge>
@endforeach
</div>
@endif
@if (filled($entry['proof_access_state'] ?? null))
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ __('localization.review.proof_access_state') }}: {{ \Illuminate\Support\Str::headline((string) $entry['proof_access_state']) }}
</div>
@endif
@else
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
</div>
@php
$detailParts = collect([
$entry['severity'] ?? null,
$entry['status'] ?? null,
$entry['governance_state'] ?? null,
$entry['outcome'] ?? null,
])->filter(fn ($value) => is_string($value) && trim($value) !== '')->map(fn (string $value) => \Illuminate\Support\Str::headline($value))->all();
@endphp
@if ($detailParts !== [])
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ implode(' · ', $detailParts) }}</div>
@endif
@if ($detailParts !== [])
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ implode(' · ', $detailParts) }}</div>
@endif
</div>
@endforeach

View File

@ -11,18 +11,6 @@
$compressedOutcome = is_array($state['compressed_outcome'] ?? null) ? $state['compressed_outcome'] : [];
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
$customerWorkspaceMode = (bool) ($state['customer_workspace_mode'] ?? false);
$controlInterpretation = is_array($state['control_interpretation'] ?? null) ? $state['control_interpretation'] : [];
$governancePackage = is_array($state['governance_package'] ?? null) ? $state['governance_package'] : [];
$packageAvailability = is_array($governancePackage['availability'] ?? null) ? $governancePackage['availability'] : [];
$packageTopFindings = is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [];
$packageAcceptedRisks = is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [];
$packageGovernanceDecisions = is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : [];
$controlControls = is_array($controlInterpretation['controls'] ?? null) ? $controlInterpretation['controls'] : [];
$controlVersion = is_string($controlInterpretation['version_key'] ?? null) ? $controlInterpretation['version_key'] : null;
$controlDisclosure = is_string($controlInterpretation['non_certification_disclosure'] ?? null)
? $controlInterpretation['non_certification_disclosure']
: null;
$controlLimitations = is_array($controlInterpretation['limitations'] ?? null) ? $controlInterpretation['limitations'] : [];
$decisionDirection = is_string($compressedOutcome['decisionDirection'] ?? null)
? trim((string) $compressedOutcome['decisionDirection'])
: null;
@ -32,19 +20,6 @@
$publicationReason = is_string($compressedOutcome['primaryReason'] ?? null) && trim((string) $compressedOutcome['primaryReason']) !== ''
? trim((string) $compressedOutcome['primaryReason'])
: null;
$packageNextStep = $publicationNextAction;
if ($packageNextStep === null) {
$firstNextAction = $nextActions[0] ?? null;
$packageNextStep = is_string($firstNextAction) && trim($firstNextAction) !== '' ? $firstNextAction : null;
}
$assessmentControls = array_slice($controlControls, 0, 2);
$additionalAssessmentControls = max(count($controlControls) - count($assessmentControls), 0);
$packageAvailabilityColor = match ($packageAvailability['state'] ?? 'gray') {
'available' => 'success',
'partial' => 'warning',
'blocked', 'expired' => 'danger',
default => 'gray',
};
@endphp
<div class="space-y-4">
@ -98,126 +73,6 @@
@endforeach
</dl>
@if ($customerWorkspaceMode && $governancePackage !== [])
<div class="space-y-3 rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ __('localization.review.governance_package') }}
</div>
@if (filled($governancePackage['delivery_note'] ?? null))
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ $governancePackage['delivery_note'] }}
</div>
@endif
</div>
@if ($packageAvailability !== [])
<x-filament::badge :color="$packageAvailabilityColor" size="sm">
{{ $packageAvailability['label'] ?? __('localization.review.unavailable') }}
</x-filament::badge>
@endif
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.primary_action') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{{ __('localization.review.download_governance_package') }}</div>
</div>
@if ($packageNextStep !== null)
<div class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
<div class="text-[11px] font-semibold uppercase tracking-wide text-primary-700 dark:text-primary-200">{{ __('localization.review.next_step') }}</div>
<div class="mt-1 text-sm text-primary-900 dark:text-primary-100">{{ $packageNextStep }}</div>
</div>
@endif
</div>
@if (filled($governancePackage['executive_summary'] ?? null))
<div class="text-sm text-gray-700 dark:text-gray-300">
{{ $governancePackage['executive_summary'] }}
</div>
@endif
@if (filled($governancePackage['evidence_basis_summary'] ?? null))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.evidence_basis') }}</div>
<div class="mt-1">{{ $governancePackage['evidence_basis_summary'] }}</div>
</div>
@endif
@if ($packageAvailability !== [] && filled($packageAvailability['description'] ?? null))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
{{ $packageAvailability['description'] }}
</div>
@endif
@if ($packageTopFindings !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_findings') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($packageTopFindings as $finding)
@php
$findingTitle = is_string($finding['title'] ?? null) ? $finding['title'] : __('localization.review.control');
$findingSummary = is_string($finding['summary'] ?? null) ? $finding['summary'] : null;
@endphp
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $findingTitle }}</div>
@if ($findingSummary !== null && trim($findingSummary) !== '')
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $findingSummary }}</div>
@endif
</li>
@endforeach
</ul>
</div>
@endif
@if ($packageAcceptedRisks !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.accepted_risks') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($packageAcceptedRisks as $risk)
@php
$riskTitle = is_string($risk['title'] ?? null) ? $risk['title'] : __('localization.review.accepted_risks');
$riskSummary = is_string($risk['summary'] ?? null) ? $risk['summary'] : null;
@endphp
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $riskTitle }}</div>
@if ($riskSummary !== null && trim($riskSummary) !== '')
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $riskSummary }}</div>
@endif
</li>
@endforeach
</ul>
</div>
@endif
@if ($packageGovernanceDecisions !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.governance_decisions') }}</div>
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
@foreach ($packageGovernanceDecisions as $decision)
@php
$decisionTitle = is_string($decision['title'] ?? null) ? $decision['title'] : __('localization.review.governance_decisions');
$decisionSummary = is_string($decision['summary'] ?? null) ? $decision['summary'] : null;
@endphp
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">
<div class="font-medium">{{ $decisionTitle }}</div>
@if ($decisionSummary !== null && trim($decisionSummary) !== '')
<div class="mt-1 text-xs">{{ $decisionSummary }}</div>
@endif
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
@if ($highlights !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div>
@ -244,106 +99,6 @@
</div>
@endif
@if ($customerWorkspaceMode && $controlInterpretation !== [])
<div class="space-y-3 rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ __('localization.review.assessment_basis') }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ __('localization.review.assessment_basis_description') }}
</div>
</div>
<x-filament::badge color="gray" size="sm">
{{ __('localization.review.customer_safe') }}
</x-filament::badge>
</div>
@if ($controlDisclosure !== null)
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
{{ $controlDisclosure }}
</div>
@endif
@if ($assessmentControls !== [])
<div class="space-y-2">
@foreach ($assessmentControls as $control)
@php
$readinessBucket = is_string($control['readiness_bucket'] ?? null) ? $control['readiness_bucket'] : 'review_recommended';
$readinessColor = match ($readinessBucket) {
'follow_up_required' => 'warning',
'review_recommended' => 'info',
'evidence_on_record' => 'success',
default => 'gray',
};
$limitationLabels = is_array($control['limitation_labels'] ?? null) ? $control['limitation_labels'] : [];
@endphp
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 dark:border-gray-800 dark:bg-gray-950/60">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $control['control_name'] ?? __('localization.review.control') }}
</div>
<x-filament::badge :color="$readinessColor" size="sm">
{{ $control['readiness_label'] ?? __('localization.review.review_recommended') }}
</x-filament::badge>
</div>
@if (filled($control['customer_summary'] ?? null))
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ $control['customer_summary'] }}
</div>
@endif
@if (filled($control['evidence_basis_summary'] ?? null))
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $control['evidence_basis_summary'] }}
</div>
@endif
@if (filled($control['recommended_next_action'] ?? null))
<div class="mt-3 rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
{{ $control['recommended_next_action'] }}
</div>
@endif
@if ($limitationLabels !== [])
<div class="mt-2 flex flex-wrap gap-1">
@foreach ($limitationLabels as $label)
@continue(! is_string($label) || trim($label) === '')
<x-filament::badge color="gray" size="sm">
{{ $label }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@if ($additionalAssessmentControls > 0)
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('localization.review.additional_controls', ['count' => $additionalAssessmentControls]) }}
</div>
@endif
@else
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
{{ __('localization.review.control_readiness_unmapped_description') }}
</div>
@endif
@if ($controlLimitations !== [])
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('localization.review.control_limitations_summary', ['limitations' => implode(', ', array_map(fn (string $flag): string => \App\Support\Governance\Controls\ComplianceEvidenceMappingV1::limitationLabel($flag), array_filter($controlLimitations, 'is_string')))]) }}
</div>
@endif
</div>
@endif
@if ($contextLinks !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div>

View File

@ -12,10 +12,6 @@
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ __('localization.review.customer_workspace_canonical_note') }}
</div>
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
{{ __('localization.review.customer_workspace_non_certification_disclosure') }}
</div>
</div>
</x-filament::section>

View File

@ -82,36 +82,20 @@
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Open customer workspace')
->waitForText('Customer-safe governance package index')
->waitForText('Customer-safe review workspace')
->assertSee('Clear filters')
->assertSee('Open review')
->assertSee('Governance package')
->assertSee('Status')
->assertSee('Evidence')
->assertSee('This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.')
->assertSee('Partial')
->assertSee('Review required')
->assertSee('Available')
->assertSee('Review package')
->assertDontSee('Publishable')
->assertDontSee('No mapped controls')
->assertDontSee('Compliance evidence mapping v1')
->assertSee('Open latest review')
->assertSee('Current review pack available')
->assertSee('Proof summary available')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->click('Clear filters')
->waitForText('Published Tenant')
->assertDontSee('No Published Tenant')
->assertDontSee('No published review available yet')
->click('Open review')
->waitForText('No published review available yet')
->assertSee('No published review available yet')
->click('Open latest review')
->waitForText('Outcome summary')
->assertSee('Download governance package')
->assertSee('Governance package')
->assertSee('Download current review pack')
->assertSee('Released governance record')
->assertSee('Review status')
->assertSee('Primary action')
->assertSee('Assessment basis')
->assertDontSee('Control readiness interpretation')
->assertDontSee('Compliance evidence mapping v1')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')

View File

@ -1,7 +1,6 @@
<?php
use App\Jobs\BulkPolicyExportJob;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
@ -59,58 +58,3 @@
'tenant_id' => $tenant->id,
]);
});
test('bulk export blocks provider-missing policies before creating items', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'missing_from_provider_at' => now(),
]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'policy_type' => $policy->policy_type,
'version_number' => 1,
'snapshot' => ['test' => 'data'],
'captured_at' => now(),
]);
$opRun = OperationRun::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'initiator_name' => $user->name,
'type' => 'policy.export',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => 'policy-export-missing-test',
'context' => [
'policy_ids' => [$policy->id],
'backup_name' => 'Missing Backup',
],
]);
$job = new BulkPolicyExportJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
policyIds: [$policy->id],
backupName: 'Missing Backup',
backupDescription: null,
operationRun: $opRun,
);
$job->handle(app(OperationRunService::class));
$opRun->refresh();
expect($opRun->status)->toBe('completed')
->and($opRun->outcome)->toBe('failed')
->and($opRun->failure_summary[0]['code'] ?? null)->toBe('policy.provider_missing');
$backupSet = BackupSet::query()->where('name', 'Missing Backup')->firstOrFail();
expect($backupSet->status)->toBe('failed')
->and((int) $backupSet->item_count)->toBe(0)
->and($backupSet->items()->count())->toBe(0);
});

View File

@ -50,9 +50,6 @@
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => '123',
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => 'compliance_evidence_mapping.v1',
]))
->assertOk();
@ -64,8 +61,5 @@
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('evidence_snapshot')
->and(data_get($audit?->metadata, 'evidence_snapshot_id'))->toBe((int) $snapshot->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE)
->and(data_get($audit?->metadata, 'review_id'))->toBe('123')
->and(data_get($audit?->metadata, 'tenant_filter_id'))->toBe((string) $tenant->getKey())
->and(data_get($audit?->metadata, 'interpretation_version'))->toBe('compliance_evidence_mapping.v1');
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE);
});

View File

@ -451,9 +451,6 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => '456',
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => 'compliance_evidence_mapping.v1',
]))
->assertOk()
->assertSee('Evidence dimensions')
@ -465,12 +462,7 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::withQueryParams([
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'review_id' => '456',
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => 'compliance_evidence_mapping.v1',
])
Livewire::withQueryParams(['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
->actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionDoesNotExist('refresh_evidence')

View File

@ -146,7 +146,7 @@ public function request(string $method, string $path, array $options = []): Grap
]);
});
test('backup service skips ignored and provider-missing policies', function () {
test('backup service skips ignored policies', function () {
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->once()
@ -194,36 +194,14 @@ public function request(string $method, string $path, array $options = []): Grap
'ignored_at' => now(),
]);
$policyC = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-3',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Policy C',
'platform' => 'windows',
'last_synced_at' => now(),
'missing_from_provider_at' => now(),
]);
$policyD = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-4',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Policy D',
'platform' => 'windows',
'last_synced_at' => now(),
'ignored_at' => now(),
'missing_from_provider_at' => now(),
]);
$service = app(\App\Services\Intune\BackupService::class);
$backupSet = $service->createBackupSet(
tenant: $tenant,
policyIds: [$policyA->id, $policyB->id, $policyC->id, $policyD->id],
policyIds: [$policyA->id, $policyB->id],
actorEmail: 'tester@example.com',
actorName: 'Tester',
);
expect($backupSet->item_count)->toBe(1);
expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]);
expect($policyD->currentBackupBlockedReason())->toBe(Policy::VISIBILITY_PROVIDER_MISSING);
});

View File

@ -81,46 +81,6 @@
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup set update queued');
});
test('policy picker keeps provider-missing policies visible but blocks add run creation', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'name' => 'Test backup',
]);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'display_name' => 'Provider missing policy',
'ignored_at' => null,
'missing_from_provider_at' => now()->subHour(),
'last_synced_at' => now()->subDays(30),
]);
Livewire::actingAs($user)
->test(BackupSetPolicyPickerTable::class, [
'backupSetId' => $backupSet->id,
])
->set('tableFilters.visibility.value', 'provider_missing')
->assertCanSeeTableRecords([$policy])
->assertSee('Provider missing')
->callTableBulkAction('add_selected_to_backup_set', [$policy])
->assertHasNoTableBulkActionErrors();
Queue::assertNothingPushed();
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_set.update')
->exists())->toBeFalse();
});
test('policy picker table reuses an active run on double click (idempotency)', function () {
Queue::fake();

View File

@ -10,7 +10,6 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
@ -145,8 +144,6 @@
});
it('summarizes governed subjects, readiness, and save-forward feedback for current selector payloads', function (): void {
App::setLocale('en');
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
@ -161,7 +158,7 @@
];
expect(BaselineProfileResource::scopeSummaryText($payload))
->toBe(__('localization.policy.taxonomy.policies').': Device Configuration; Platform foundation configuration resources: Assignment Filter')
->toBe('Intune policies: Device Configuration; Platform foundation configuration resources: Assignment Filter')
->and(BaselineProfileResource::scopeSupportReadinessText($payload))
->toBe('Capture: ready. Compare: ready.')
->and(BaselineProfileResource::scopeSelectionFeedbackText($payload))
@ -169,8 +166,6 @@
});
it('shows normalization lineage on the baseline profile detail surface before a legacy row is saved forward', function (): void {
App::setLocale('en');
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
@ -199,7 +194,7 @@
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profileId])
->assertSee('Governed subject summary')
->assertSee(__('localization.policy.taxonomy.policies').': Device Configuration')
->assertSee('Intune policies: Device Configuration')
->assertSee('Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.');
});

View File

@ -21,7 +21,6 @@
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\App;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
@ -68,8 +67,6 @@ function makeWorkspaceListComponent(string $role = 'owner'): Testable
}
it('defines the policies empty state contract and keeps the sync CTA outcome intact', function (): void {
App::setLocale('en');
Queue::fake();
bindFailHardGraphClient();
@ -81,19 +78,19 @@ function makeWorkspaceListComponent(string $role = 'owner'): Testable
$component = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync'])
->assertSee(__('localization.policy.resource.empty_state_heading'))
->assertSee(__('localization.policy.resource.empty_state_description'));
->assertSee('No policies synced yet')
->assertSee('Sync your first tenant to see Intune policies here.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.resource.empty_state_description'));
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path');
$action = getFeature122EmptyStateAction($component, 'sync');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'));
expect($action?->getLabel())->toBe('Sync from Intune');
$component
->mountAction('sync')

View File

@ -1,196 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function getPolicyInventoryEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
it('renders policy inventory list copy from the active German locale', function (): void {
App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListPolicies::class)
->assertSee(__('localization.policy.common.policies'))
->assertSee(__('localization.policy.resource.empty_state_heading'))
->assertSee(__('localization.policy.resource.empty_state_description'));
$action = getPolicyInventoryEmptyStateAction($component, 'sync');
expect($action)->not->toBeNull()
->and($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'));
});
it('renders source-unavailable policy labels from the active German locale', function (): void {
App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'German Source Unavailable Policy',
'ignored_at' => null,
'missing_from_provider_at' => now()->subMinute(),
]);
Livewire::test(ListPolicies::class)
->set('tableFilters.visibility.value', 'provider_missing')
->assertSee(__('localization.policy.badges.source_unavailable'));
$badge = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'provider_missing');
expect($badge->label)->toBe(__('localization.policy.badges.source_unavailable'));
});
it('renders policy version list copy from the active German locale', function (): void {
App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class)
->assertSee(__('localization.policy.versions.empty_state_heading'))
->assertSee(__('localization.policy.versions.empty_state_description'))
->assertSee(__('localization.policy.versions.open_backup_sets'));
});
it('renders the restore-to-Microsoft-Intune action from the active German locale', function (): void {
App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'metadata' => [],
]);
Livewire::test(VersionsRelationManager::class, [
'ownerRecord' => $policy,
'pageClass' => ViewPolicy::class,
])->assertSee(__('localization.policy.relation.restore_to_microsoft_intune'));
});
it('renders policy version quality and related labels from the active German locale', function (): void {
App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Windows Policy',
'platform' => 'all',
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'version_number' => 1,
'platform' => 'all',
'snapshot' => ['id' => 'policy-1'],
'metadata' => [],
]);
Livewire::test(ListPolicyVersions::class)
->assertSee(__('localization.policy.common.captured'))
->assertSee(__('localization.policy.versions.snapshot_mode_full'))
->assertSee(__('localization.policy.versions.compact_summary_full_payload'))
->assertSee(__('localization.policy.versions.next_action_open_version_detail'))
->assertSee(__('localization.policy.versions.related_action_view_policy'))
->assertSee(__('localization.policy.common.platform_label_all'));
});
it('renders policy detail and capture-snapshot copy from the active German locale', function (): void {
App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Enrollment Notifications',
'platform' => 'all',
]);
PolicyVersion::factory()->create([
'tenant_id' => $tenant->getKey(),
'policy_id' => $policy->getKey(),
'snapshot' => [
'displayName' => 'Enrollment Notifications',
'platforms' => 'all',
'lastModifiedDateTime' => '2026-01-04T11:22:52Z',
'createdDateTime' => '2026-01-04T11:22:52Z',
],
'metadata' => [],
]);
Livewire::withQueryParams(['tab' => 'general::tab'])
->test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
->assertSee(__('localization.policy.resource.capture_snapshot_action'))
->assertSee(__('localization.policy.resource.details_section'))
->assertSee(__('localization.policy.resource.tab_general'))
->assertSee(__('localization.policy.resource.general_field_platforms'))
->assertSee(__('localization.policy.common.platform_label_all'))
->assertSee(__('localization.policy.resource.general_field_last_modified'))
->assertSee(__('localization.policy.resource.general_field_created'));
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
->assertActionExists('capture_snapshot', function (Action $action): bool {
return $action->getLabel() === __('localization.policy.resource.capture_snapshot_action')
&& $action->isConfirmationRequired()
&& (string) $action->getModalHeading() === __('localization.policy.resource.capture_snapshot_modal_heading')
&& str_contains((string) $action->getModalDescription(), __('localization.policy.resource.capture_snapshot_modal_subheading'))
&& str_contains((string) $action->getModalDescription(), __('localization.policy.common.source_microsoft_intune'));
});
});

View File

@ -5,7 +5,6 @@
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\App;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -47,8 +46,6 @@
});
test('policy list keeps the standard table defaults and persists state in-session', function () {
App::setLocale('en');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
@ -65,7 +62,7 @@
$table = $component->instance()->getTable();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('external_id')?->isToggledHiddenByDefault())->toBeTrue();

View File

@ -1,75 +0,0 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Models\Policy;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('filters active, ignored, and provider-missing policy states distinctly', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$active = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Active policy',
'ignored_at' => null,
'missing_from_provider_at' => null,
]);
$missing = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Provider missing policy',
'ignored_at' => null,
'missing_from_provider_at' => now()->subHour(),
]);
$combined = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Ignored missing policy',
'ignored_at' => now()->subDay(),
'missing_from_provider_at' => now()->subHour(),
]);
Livewire::actingAs($user)
->test(ListPolicies::class)
->assertCanSeeTableRecords([$active])
->assertCanNotSeeTableRecords([$missing, $combined])
->set('tableFilters.visibility.value', 'provider_missing')
->assertCanSeeTableRecords([$missing, $combined])
->assertCanNotSeeTableRecords([$active])
->set('tableFilters.visibility.value', 'ignored')
->assertCanSeeTableRecords([$combined])
->assertCanNotSeeTableRecords([$active, $missing]);
});
it('keeps provider-missing sync retry available and current export disabled', function (): void {
App::setLocale('en');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->getKey(),
'display_name' => 'Provider missing policy',
'ignored_at' => null,
'missing_from_provider_at' => now()->subHour(),
]);
Livewire::actingAs($user)
->test(ListPolicies::class)
->set('tableFilters.visibility.value', 'provider_missing')
->assertCanSeeTableRecords([$policy])
->assertSee(__('localization.policy.badges.source_unavailable'))
->assertTableActionEnabled('sync', $policy)
->assertTableActionDisabled('export', $policy);
});

View File

@ -11,11 +11,8 @@
use App\Models\Policy;
use App\Models\PolicyVersion;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\App;
it('shows parent policy and snapshot evidence links for policy versions', function (): void {
App::setLocale('en');
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
@ -60,6 +57,6 @@
$this->get(PolicyVersionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee(__('localization.policy.versions.related_action_view_policy'))
->assertSee('View policy')
->assertSee(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant), false);
});

View File

@ -13,7 +13,7 @@
uses(RefreshDatabase::class);
test('restore selection options are grouped and preserve provider-missing continuity', function () {
test('restore selection options are grouped and filter ignored policies', function () {
$tenant = Tenant::factory()->create(['status' => 'active']);
$tenant->makeCurrent();
@ -39,26 +39,9 @@
'platform' => 'windows',
'ignored_at' => now(),
]);
$providerMissingPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-provider-missing',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Provider Missing Policy',
'platform' => 'windows',
'missing_from_provider_at' => now(),
]);
$combinedPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-ignored-provider-missing',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Ignored Provider Missing Policy',
'platform' => 'windows',
'ignored_at' => now(),
'missing_from_provider_at' => now(),
]);
$backupSet = BackupSet::factory()->for($tenant)->create([
'item_count' => 6,
'item_count' => 4,
]);
$policyItem = BackupItem::factory()
@ -85,30 +68,6 @@
])
->create();
$providerMissingItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => $providerMissingPolicy->id,
'policy_identifier' => $providerMissingPolicy->external_id,
'policy_type' => $providerMissingPolicy->policy_type,
'platform' => $providerMissingPolicy->platform,
'payload' => ['id' => $providerMissingPolicy->external_id],
])
->create();
$combinedItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => $combinedPolicy->id,
'policy_identifier' => $combinedPolicy->external_id,
'policy_type' => $combinedPolicy->policy_type,
'platform' => $combinedPolicy->platform,
'payload' => ['id' => $combinedPolicy->external_id],
])
->create();
$scopeTagItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
@ -175,14 +134,6 @@
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
expect($flattenedOptions)->toHaveKey($providerMissingItem->id);
expect($flattenedOptions[$providerMissingItem->id])->toContain('Provider Missing Policy')
->and($flattenedOptions[$providerMissingItem->id])->toContain('provider missing now');
expect($flattenedOptions)->toHaveKey($combinedItem->id);
expect($flattenedOptions[$combinedItem->id])->toContain('Ignored Provider Missing Policy')
->and($flattenedOptions[$combinedItem->id])->toContain('provider missing now');
expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
expect($flattenedOptions[$scopeTagItem->id])->toContain('Scope Tag Alpha');

View File

@ -15,7 +15,6 @@
use Filament\Facades\Filament;
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
@ -41,8 +40,6 @@ function spec125BaselineTenantContext(): array
}
it('keeps the policy resource list as the baseline resource-standard example', function (): void {
App::setLocale('en');
[$user] = spec125BaselineTenantContext();
$component = Livewire::actingAs($user)->test(ListPolicies::class)
@ -56,8 +53,8 @@ function spec125BaselineTenantContext(): array
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.resource.empty_state_description'));
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
expect(array_keys($table->getVisibleColumns()))->toContain('display_name', 'policy_type', 'platform', 'last_synced_at');
$displayName = $table->getColumn('display_name');
@ -73,8 +70,6 @@ function spec125BaselineTenantContext(): array
});
it('keeps the policy versions relation manager on the standard relation-manager contract', function (): void {
App::setLocale('en');
[$user, $tenant] = spec125BaselineTenantContext();
$policy = Policy::factory()->create([
@ -91,8 +86,8 @@ function spec125BaselineTenantContext(): array
expect($table->getDefaultSortColumn())->toBe('version_number');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::relationManager());
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.relation.no_versions_captured'));
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.relation.no_versions_captured_description'));
expect($table->getEmptyStateHeading())->toBe('No versions captured');
expect($table->getEmptyStateDescription())->toBe('Capture or sync this policy again to create version history entries.');
expect($table->getColumn('version_number')?->isSortable())->toBeTrue();
expect($table->getColumn('captured_at')?->isSortable())->toBeTrue();
expect($table->getColumn('policy_type')?->isToggleable())->toBeTrue();

View File

@ -96,8 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap
->where('external_id', 'config-1')
->firstOrFail();
expect($existingConfig->ignored_at)->toBeNull();
expect($existingConfig->missing_from_provider_at)->not->toBeNull();
expect($existingConfig->ignored_at)->not->toBeNull();
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'config-skip')->exists())->toBeFalse();
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->whereNull('missing_from_provider_at')->exists())->toBeTrue();
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->exists())->toBeTrue();
});

View File

@ -47,7 +47,7 @@ public function request(string $method, string $path, array $options = []): Grap
}
}
test('sync preserves local ignore when policies still exist in Intune', function () {
test('sync revives ignored policies when they exist in Intune', function () {
$tenant = Tenant::create([
'tenant_id' => 'test-tenant',
'name' => 'Test Tenant',
@ -88,14 +88,13 @@ public function request(string $method, string $path, array $options = []): Grap
// Refresh the policy
$policy->refresh();
// Provider reappearance updates local metadata, but only a user action clears local ignore.
expect($policy->ignored_at)->not->toBeNull();
expect($policy->missing_from_provider_at)->toBeNull();
// Policy should no longer be ignored
expect($policy->ignored_at)->toBeNull();
expect($policy->display_name)->toBe('Test Policy (Updated)');
expect($policy->last_synced_at)->not->toBeNull();
});
test('sync updates ignored policies without reviving them', function () {
test('sync creates new policies even if ignored ones exist with same external_id', function () {
$tenant = Tenant::create([
'tenant_id' => 'test-tenant-2',
'name' => 'Test Tenant 2',
@ -150,17 +149,15 @@ public function request(string $method, string $path, array $options = []): Grap
// Sync policies
app(PolicySyncService::class)->syncPolicies($tenant);
// Both provider-visible policies remain locally ignored until explicitly restored.
expect(Policy::active()->count())->toBe(0);
expect(Policy::ignored()->count())->toBe(2);
// All policies should now be active
expect(Policy::active()->count())->toBe(2);
expect(Policy::ignored()->count())->toBe(0);
$policyAbc = Policy::where('external_id', 'policy-abc')->first();
expect($policyAbc->display_name)->toBe('Restored Policy ABC');
expect($policyAbc->ignored_at)->not->toBeNull();
expect($policyAbc->missing_from_provider_at)->toBeNull();
expect($policyAbc->ignored_at)->toBeNull();
$policyDef = Policy::where('external_id', 'policy-def')->first();
expect($policyDef->display_name)->toBe('Restored Policy DEF');
expect($policyDef->ignored_at)->not->toBeNull();
expect($policyDef->missing_from_provider_at)->toBeNull();
expect($policyDef->ignored_at)->toBeNull();
});

View File

@ -1,157 +0,0 @@
<?php
use App\Models\AuditLog;
use App\Models\Policy;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\mock;
uses(RefreshDatabase::class);
function tenantWithDefaultMicrosoftConnectionForProviderMissing(array $attributes = []): Tenant
{
$tenant = Tenant::factory()->create($attributes + [
'status' => 'active',
'app_client_id' => null,
'app_client_secret' => null,
]);
$connection = ProviderConnection::factory()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'is_default' => true,
'consent_status' => 'granted',
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'provider-client-'.$tenant->getKey(),
'client_secret' => 'provider-secret-'.$tenant->getKey(),
],
]);
return $tenant;
}
it('marks previously observed policies missing when provider list omits them', function (): void {
$tenant = tenantWithDefaultMicrosoftConnectionForProviderMissing();
$present = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-present',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Old present',
'ignored_at' => null,
'missing_from_provider_at' => null,
]);
$missing = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-missing',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Missing from provider',
'ignored_at' => null,
'missing_from_provider_at' => null,
]);
mock(GraphLogger::class)
->shouldReceive('logRequest', 'logResponse')
->zeroOrMoreTimes()
->andReturnNull();
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->once()
->with('deviceConfiguration', mockery::type('array'))
->andReturn(new GraphResponse(
success: true,
data: [
[
'id' => 'policy-present',
'displayName' => 'Provider present',
'platform' => 'windows',
],
],
));
app(PolicySyncService::class)->syncPolicies($tenant, [
['type' => 'deviceConfiguration', 'platform' => 'windows'],
]);
$present->refresh();
$missing->refresh();
expect($present->display_name)->toBe('Provider present')
->and($present->ignored_at)->toBeNull()
->and($present->missing_from_provider_at)->toBeNull()
->and($missing->ignored_at)->toBeNull()
->and($missing->missing_from_provider_at)->not->toBeNull()
->and($missing->visibilityState())->toBe(Policy::VISIBILITY_PROVIDER_MISSING);
expect(AuditLog::query()
->where('tenant_id', $tenant->id)
->where('action', AuditActionId::PolicyProviderMissingDetected->value)
->where('resource_id', (string) $missing->getKey())
->exists())->toBeTrue();
});
it('clears provider missing on reappearance without clearing local ignore', function (): void {
$tenant = tenantWithDefaultMicrosoftConnectionForProviderMissing();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-returned',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Returned policy',
'ignored_at' => now()->subDay(),
'missing_from_provider_at' => now()->subDay(),
]);
mock(GraphLogger::class)
->shouldReceive('logRequest', 'logResponse')
->zeroOrMoreTimes()
->andReturnNull();
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->once()
->with('deviceConfiguration', mockery::type('array'))
->andReturn(new GraphResponse(
success: true,
data: [
[
'id' => 'policy-returned',
'displayName' => 'Returned from provider',
'platform' => 'windows',
],
],
));
app(PolicySyncService::class)->syncPolicies($tenant, [
['type' => 'deviceConfiguration', 'platform' => 'windows'],
]);
$policy->refresh();
expect($policy->display_name)->toBe('Returned from provider')
->and($policy->ignored_at)->not->toBeNull()
->and($policy->missing_from_provider_at)->toBeNull()
->and($policy->visibilityState())->toBe(Policy::VISIBILITY_IGNORED_LOCALLY);
expect(AuditLog::query()
->where('tenant_id', $tenant->id)
->where('action', AuditActionId::PolicyProviderMissingCleared->value)
->where('resource_id', (string) $policy->getKey())
->exists())->toBeTrue();
});

View File

@ -40,7 +40,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
return $tenant;
}
it('marks targeted managed app configurations as provider missing during sync', function () {
it('marks targeted managed app configurations as ignored during sync', function () {
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
$policy = Policy::factory()->create([
@ -82,8 +82,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
$policy->refresh();
expect($policy->ignored_at)->toBeNull();
expect($policy->missing_from_provider_at)->not->toBeNull();
expect($policy->ignored_at)->not->toBeNull();
expect($synced)->toBeArray()->toBeEmpty();
});
@ -339,7 +338,6 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
->where('tenant_id', $tenant->id)
->where('external_id', 'esp-1')
->whereNull('ignored_at')
->whereNull('missing_from_provider_at')
->count())->toBe(1);
expect(Policy::query()
@ -347,14 +345,13 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
->where('external_id', 'esp-1')
->where('policy_type', 'endpointSecurityPolicy')
->whereNull('ignored_at')
->whereNull('missing_from_provider_at')
->count())->toBe(1);
expect(Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', 'esp-1')
->where('policy_type', 'settingsCatalogPolicy')
->whereNotNull('missing_from_provider_at')
->whereNull('ignored_at')
->count())->toBe(0);
$version->refresh();

View File

@ -61,7 +61,6 @@
expect($version->policy_type)->toBe('windowsEnrollmentStatusPage');
expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage');
expect($policy->missing_from_provider_at)->toBeNull();
});
test('reclassify command can detect ESP even when a policy has no versions', function () {
@ -104,56 +103,4 @@
$policy->refresh();
expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage');
expect($policy->missing_from_provider_at)->toBeNull();
});
test('reclassify command marks duplicate wrong rows provider missing instead of ignored', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-reclassify-duplicate',
'name' => 'Tenant Reclassify Duplicate',
'metadata' => [],
'is_current' => true,
]);
$tenant->makeCurrent();
$wrong = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'esp-duplicate',
'policy_type' => 'enrollmentRestriction',
'display_name' => 'ESP Duplicate Wrong',
'platform' => 'all',
]);
Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'esp-duplicate',
'policy_type' => 'windowsEnrollmentStatusPage',
'display_name' => 'ESP Duplicate Correct',
'platform' => 'all',
]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $wrong->id,
'version_number' => 1,
'policy_type' => 'enrollmentRestriction',
'platform' => 'all',
'created_by' => 'tester@example.com',
'captured_at' => CarbonImmutable::now(),
'snapshot' => [
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
'displayName' => 'ESP Duplicate Wrong',
],
]);
$this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true])
->assertSuccessful();
$wrong->refresh();
expect($wrong->policy_type)->toBe('enrollmentRestriction')
->and($wrong->ignored_at)->toBeNull()
->and($wrong->missing_from_provider_at)->not->toBeNull();
});

View File

@ -67,9 +67,6 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'customer_review_workspace',
'review_id' => '789',
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => 'compliance_evidence_mapping.v1',
]);
$packCount = ReviewPack::query()->count();
$operationRunCount = OperationRun::query()->count();
@ -89,9 +86,6 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
->and($audit?->resource_type)->toBe('review_pack')
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace')
->and(data_get($audit?->metadata, 'review_id'))->toBe('789')
->and(data_get($audit?->metadata, 'tenant_filter_id'))->toBe((string) $tenant->getKey())
->and(data_get($audit?->metadata, 'interpretation_version'))->toBe('compliance_evidence_mapping.v1')
->and(ReviewPack::query()->count())->toBe($packCount)
->and(OperationRun::query()->count())->toBe($operationRunCount);
});

View File

@ -152,12 +152,7 @@
setTenantPanelContext($tenant);
Livewire::withQueryParams([
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
])
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertSee('Outcome summary')
@ -176,7 +171,5 @@
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('tenant_review')
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE)
->and(data_get($audit?->metadata, 'tenant_filter_id'))->toBe((string) $tenant->getKey())
->and(data_get($audit?->metadata, 'interpretation_version'))->toBe($review->controlInterpretationVersion());
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});

View File

@ -3,7 +3,6 @@
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\Tenant;
@ -37,7 +36,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
);
}
it('keeps the latest released review as the only row action when a ready review pack exists', function (): void {
it('shows the ready review-pack action for the latest published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
@ -68,14 +67,12 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee('Governance package')
->assertSee('Open review')
->assertDontSee('Download review pack')
->assertDontSee('Current review pack available');
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Current review pack available');
});
it('keeps the customer review workspace row action visible while suspended read-only', function (): void {
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
@ -108,13 +105,12 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee('Open review')
->assertDontSee('Download review pack')
->assertDontSee('Current review pack available');
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Current review pack available');
});
it('does not expose review-pack availability as a workspace row peer action', function (): void {
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
@ -133,49 +129,12 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee('Unavailable')
->assertDontSee('No current review pack available yet')
->assertDontSee('Download review pack');
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('No current review pack available yet');
});
it('shows a partial governance-package state when the released review basis is limitation-aware', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedPartialTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee('Partial')
->assertDontSee('Download review pack');
});
it('shows expired and capability-blocked governance-package states on the workspace row surface', function (): void {
it('distinguishes expired and capability-blocked review-pack states on the workspace', function (): void {
$expiredTenant = Tenant::factory()->create(['name' => 'Expired Pack Tenant']);
[$user, $expiredTenant] = createUserWithTenant(tenant: $expiredTenant, role: 'readonly');
$blockedTenant = Tenant::factory()->create([
@ -214,12 +173,13 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$expiredTenant->fresh(), $blockedTenant->fresh()])
->assertSee('Expired')
->assertSee('Blocked')
->assertDontSee('Download review pack');
->assertSee('Review pack expired')
->assertSee('Review pack access is unavailable for this actor')
->assertTableActionHidden('download_review_pack', $expiredTenant)
->assertTableActionHidden('download_review_pack', $blockedTenant);
});
it('hides tenants without a published review from the workspace rows', function (): void {
it('hides review and pack actions for tenants without a published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
@ -238,7 +198,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanNotSeeTableRecords([$tenant->fresh()])
->assertSee('No released customer reviews match this view')
->assertDontSee('No published review available yet');
->assertTableActionHidden('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('No published review available yet');
});

View File

@ -8,7 +8,6 @@
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -92,19 +91,6 @@
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantDenied->fresh()])
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false)
->assertSee('Review the current governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.')
->assertSee('This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.')
->assertSee('Governance package')
->assertSee('Status')
->assertSee('Evidence')
->assertSee('Next step')
->assertSee('Open')
->assertSee('Open review')
->assertDontSee('Assessment status')
->assertDontSee('Publishable')
->assertDontSee('No mapped controls')
->assertDontSee('Compliance evidence mapping v1')
->assertDontSee(ComplianceEvidenceMappingV1::VERSION_KEY)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $betaPublishedReview->fresh()], $tenantB), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $olderPublishedReview->fresh()], $tenantA), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $newerInternalReview->fresh()], $tenantA), false)
@ -116,7 +102,7 @@
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $deniedPublishedReview->fresh()], $tenantDenied), false);
});
it('excludes entitled tenants without a published review from customer workspace rows', function (): void {
it('shows entitled tenants without a published review as calm absence rows', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(tenant: $tenantPublished, role: 'readonly');
@ -149,46 +135,36 @@
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantPublished->fresh()])
->assertCanNotSeeTableRecords([$tenantWithoutPublished->fresh()])
->assertDontSee('No published review')
->assertDontSee('No published review available yet')
->assertCanSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()])
->assertSee('No published review')
->assertSee('No published review available yet')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenantWithoutPublished), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false);
});
it('uses a page-level empty state when no entitled tenant has a released review', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Internal Only Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$internalOnlyReview = composeTenantReviewForTest($tenant, $user, $snapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanNotSeeTableRecords([$tenant->fresh()])
->assertSee('No released customer reviews match this view')
->assertSee('Publish a tenant review before it appears in the customer-safe workspace.')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenant), false);
});
it('summarizes accepted-risk control interpretation and evidence proof availability in customer-safe workspace rows', function (): void {
it('summarizes accepted-risk accountability and evidence proof availability in customer-safe workspace rows', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Governed Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$owner = User::factory()->create(['name' => 'Risk Owner']);
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
'risk_acceptance' => [
'status_marked_count' => 1,
'valid_governed_count' => 1,
'warning_count' => 0,
],
]),
])->save();
$finding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
FindingException::query()->create([
@ -207,14 +183,6 @@
'review_due_at' => now()->addDays(14),
]);
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -222,13 +190,9 @@
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenant->fresh()])
->assertSee('Review required')
->assertSee('Open review')
->assertDontSee('Ready for release')
->assertDontSee('1 evidence signal(s) reference this control.')
->assertDontSee('1 accepted-risk finding(s) qualify this view.')
->assertDontSee('Review the accepted-risk owner and next review date before customer delivery.')
->assertDontSee('Accepted risk influences this view');
->assertSee('1 accepted risks are governed. Accountable: Risk Owner. Re-review by')
->assertSee('Reason: Vendor patch window accepted by the customer.')
->assertSee('Proof summary available');
});
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {

View File

@ -3,8 +3,6 @@
declare(strict_types=1);
use App\Jobs\GenerateReviewPackJob;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AuditLog;
use App\Models\EvidenceSnapshot;
use App\Services\ReviewPackService;
@ -102,38 +100,3 @@
->and(data_get($publishAudit?->metadata, 'reason'))->toBe('Publishing the current review pack.')
->and(data_get($archiveAudit?->metadata, 'reason'))->toBe('Replacing with a newer governance review.');
});
it('records customer workspace interpretation metadata when a tenant review is opened', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => 'published',
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]))
->assertOk();
$audit = AuditLog::query()
->where('action', AuditActionId::TenantReviewOpened->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('tenant_review')
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE)
->and(data_get($audit?->metadata, 'tenant_filter_id'))->toBe((string) $tenant->getKey())
->and(data_get($audit?->metadata, 'interpretation_version'))->toBe($review->controlInterpretationVersion());
});

View File

@ -3,7 +3,6 @@
declare(strict_types=1);
use App\Models\Finding;
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
it('passes shared canonical control references through tenant review composition', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -12,23 +11,12 @@
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$openRisks = $review->sections->firstWhere('section_key', 'open_risks');
$executiveSummary = $review->sections->firstWhere('section_key', 'executive_summary');
$controlInterpretation = $review->sections->firstWhere('section_key', ComplianceEvidenceMappingV1::SECTION_KEY);
$controlEntries = $review->controlInterpretationControls();
expect($review->canonicalControlReferences())->toHaveCount(1)
->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1)
->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($openRisks->summary_payload['canonical_controls'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance')
->and($review->controlInterpretationVersion())->toBe(ComplianceEvidenceMappingV1::VERSION_KEY)
->and($review->controlInterpretation()['non_certification_disclosure'] ?? null)->toBeString()
->and($review->controlInterpretation()['mapped_control_count'] ?? null)->toBe(1)
->and($controlEntries)->toHaveCount(1)
->and($controlEntries[0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance')
->and($controlEntries[0]['readiness_bucket'] ?? null)->toBe('follow_up_required')
->and($controlEntries[0]['proof_access_state'] ?? null)->toBe('available')
->and($controlInterpretation?->summary_payload['version_key'] ?? null)->toBe(ComplianceEvidenceMappingV1::VERSION_KEY)
->and($controlInterpretation?->render_payload['entries'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance');
->and($openRisks->summary_payload['canonical_controls'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance');
});
it('excludes removed acknowledged findings from open risk highlights', function (): void {

View File

@ -14,16 +14,7 @@
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
expect($review->evidence_snapshot_id)->toBe((int) $snapshot->getKey())
->and($review->sections)->toHaveCount(7)
->and($review->sections->pluck('section_key')->all())->toBe([
'executive_summary',
'control_interpretation',
'open_risks',
'accepted_risks',
'permission_posture',
'baseline_drift_posture',
'operations_health',
])
->and($review->sections)->toHaveCount(6)
->and($review->summary['evidence_basis']['snapshot_id'])->toBe((int) $snapshot->getKey());
Finding::factory()->create([

View File

@ -84,34 +84,15 @@
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
'tenant_filter_id' => (string) $tenant->getKey(),
'interpretation_version' => $review->controlInterpretationVersion(),
]))
->assertOk()
->assertSee('Released governance record')
->assertSee('This released review is available for customer-safe governance consumption.')
->assertSee('Governance package')
->assertSee('Review status')
->assertSee('Evidence')
->assertSee('Accepted risk status')
->assertSee('Last review')
->assertSee('Primary action')
->assertSee('Download governance package')
->assertSee('Anchored to evidence snapshot #')
->assertSee('Assessment basis')
->assertSee('Evidence basis')
->assertSee('Review the surfaced findings with the tenant and agree ownership plus follow-up timing.')
->assertSee('Evidence snapshot')
->assertSee('review_id='.$review->getKey(), false)
->assertSee('source_surface=customer_review_workspace', false)
->assertDontSeeText('Compliance evidence mapping v1')
->assertDontSeeText($review->controlInterpretationVersion())
->assertDontSeeText('Control readiness interpretation')
->assertDontSee('Reason owner')
->assertDontSee('Platform reason family')
->assertDontSee('Fingerprint')
->assertDontSee('Download current review pack')
->assertDontSee(OperationRunLinks::tenantlessView((int) $review->operation_run_id), false)
->assertDontSee('Inspect the latest review composition or refresh run.');
});

View File

@ -208,8 +208,6 @@ function tenantReviewContractHeaderActions(Testable $component): array
->assertActionDoesNotExist('export_executive_pack')
->assertActionDoesNotExist('archive_review');
$component->assertActionExists('download_current_review_pack', fn (Action $action): bool => $action->getLabel() === 'Download governance package');
$topLevelActionNames = collect(tenantReviewContractHeaderActions($component))
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()

View File

@ -5,37 +5,14 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use Illuminate\Support\Facades\App;
it('maps policy provider presence values to canonical badge semantics', function (): void {
App::setLocale('en');
$active = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'active');
expect($active->label)->toBe(__('localization.policy.badges.active'));
expect($active->color)->toBe('success');
$ignored = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'ignored_locally');
expect($ignored->label)->toBe(__('localization.policy.badges.ignored_locally'));
expect($ignored->color)->toBe('warning');
$missing = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'provider_missing');
expect($missing->label)->toBe(__('localization.policy.badges.source_unavailable'));
expect($missing->color)->toBe('warning');
$combined = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'ignored_locally_provider_missing');
expect($combined->label)->toBe(__('localization.policy.badges.ignored_source_unavailable'));
expect($combined->color)->toBe('danger');
});
it('maps policy snapshot mode values to canonical badge semantics', function (): void {
App::setLocale('en');
$full = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'full');
expect($full->label)->toBe(__('localization.policy.versions.snapshot_mode_full'));
expect($full->label)->toBe('Full');
expect($full->color)->toBe('success');
$metadataOnly = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'metadata_only');
expect($metadataOnly->label)->toBe(__('localization.policy.versions.snapshot_mode_metadata_only'));
expect($metadataOnly->label)->toBe('Metadata only');
expect($metadataOnly->color)->toBe('warning');
});

View File

@ -2,9 +2,6 @@
declare(strict_types=1);
use App\Models\Finding;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Services\TenantReviews\TenantReviewComposer;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
@ -20,15 +17,12 @@
expect(array_column($payload['sections'], 'section_key'))->toBe([
'executive_summary',
'control_interpretation',
'open_risks',
'accepted_risks',
'permission_posture',
'baseline_drift_posture',
'operations_health',
])->and($payload['summary']['section_count'])->toBe(7)
->and($payload['summary']['control_interpretation']['version_key'] ?? null)->toBe('compliance_evidence_mapping.v1')
->and($payload['status'])->toBe(TenantReviewStatus::Ready->value);
])->and($payload['status'])->toBe(TenantReviewStatus::Ready->value);
});
it('marks reviews as ready when evidence is partial but required sections are still present', function (): void {
@ -45,59 +39,3 @@
->and($payload['status'])->toBe(TenantReviewStatus::Ready->value)
->and($payload['summary']['publish_blockers'])->toBe([]);
});
it('derives a governance package summary from existing review and interpretation truth', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$payload = app(TenantReviewComposer::class)->compose($snapshot);
$package = $payload['summary']['governance_package'] ?? null;
expect($package)->toBeArray()
->and($package['delivery_artifact_family'] ?? null)->toBe('review_pack')
->and($package['interpretation_version'] ?? null)->toBe('compliance_evidence_mapping.v1')
->and($package['executive_summary'] ?? null)->toBeString()
->and($package['top_findings'] ?? null)->toBeArray()
->and($package['evidence_basis_summary'] ?? null)->toBeString()
->and($package['accepted_risks'] ?? null)->toBeArray()
->and($package['governance_decisions'] ?? null)->toBeArray()
->and($package['supporting_artifact_links'] ?? null)->toBeArray();
});
it('keeps governance decision follow-up entries out of accepted risks', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
/** @var FindingExceptionService $exceptionService */
$exceptionService = app(FindingExceptionService::class);
$validFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
$requested = $exceptionService->request($validFinding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Temporary exception while remediation is scheduled',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exceptionService->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Approved with controls',
]);
$followUpFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$package = app(TenantReviewComposer::class)->compose($snapshot)['summary']['governance_package'] ?? [];
expect(collect($package['accepted_risks'] ?? [])->pluck('finding_id')->all())
->toBe([(int) $validFinding->getKey()])
->and(collect($package['governance_decisions'] ?? [])->pluck('finding_id')->all())
->toBe([(int) $followUpFinding->getKey()]);
});

View File

@ -10,6 +10,8 @@ # Discoveries
Items that are already tracked in [spec-candidates.md](spec-candidates.md) or [roadmap.md](roadmap.md) should not remain here.
**Last reviewed**: 2026-04-30
---
## 2026-04-30 — 2026-03-15 architecture hardening cluster moved out of discoveries

View File

@ -4,7 +4,6 @@ # TenantPilot Implementation Ledger
> **Last reviewed:** 2026-04-30
> **Use for:** Repo-based implementation status and product-surface maturity assessment
> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch
> **Scoped maintenance:** 2026-05-01 provider-missing / Spec 261 alignment and doc hygiene only; no full repo-wide maturity re-audit was performed.
## Purpose
@ -58,7 +57,7 @@ ## Roadmap Coverage Summary
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real, aber die Customer-Review-Consumption ist noch nicht voll productized. |
| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. |
| Provider-missing policy visibility follow-up | specified | weak | no | no | no | Spec 261 ist als schmaler policy-only Follow-up vorbereitet; die breitere Lifecycle-Taxonomie bleibt strategisch und unimplementiert. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
| Private AI Execution Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
@ -130,7 +129,7 @@ ## Planned But Not Implemented
- Standardization & Policy Quality / Intune Linting
- PSA / Ticketing Handoff
- Cross-Tenant Compare and Promotion v1
- Provider-Missing Policy Visibility & Restore Continuity v1 (Spec 261, specified only)
- Policy Lifecycle / Ghost Policies
- Later compliance overlays beyond the current control/evidence foundation
## Release Readiness
@ -303,4 +302,4 @@ ## Evidence Sources
## Last Updated
2026-05-01 on branch `261-provider-missing-policy-visibility` (scoped provider-missing/docs alignment only)
2026-04-29 on branch `platform-dev`

View File

@ -8,6 +8,8 @@ # Operator Semantic Taxonomy
> Canonical operator-facing state reference for the first implementation slice.
> Downstream specs and badge mappings must reuse this vocabulary instead of inventing local synonyms.
**Last reviewed**: 2026-03-21
---
## Core Rules

View File

@ -8,6 +8,8 @@ # Product Principles
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
> New specs must align with these. If a principle needs to change, update this file first.
**Last reviewed**: 2026-04-09
---
## Identity & Isolation

View File

@ -4,13 +4,14 @@ # Product Roadmap
> **Last reviewed:** 2026-04-30
> **Use for:** Current product roadmap, release themes, and prioritization context
> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification
> **Scoped maintenance:** 2026-05-01 lifecycle/provider-missing wording alignment only; no full roadmap re-review was performed.
>
> Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs.
>
> Queue boundary: the active candidate queue lives in `spec-candidates.md`; older audit-derived candidate packages are historical inputs only.
**Last updated**: 2026-04-30
---
## Release History
@ -172,8 +173,6 @@ ### Workspace, Tenant & Managed Object Lifecycle Governance
**Important boundary**: Do not implement a narrow policy-only ghost lifecycle patch, Laravel `SoftDeletes` rollout, workspace deletion flow, tenant deletion flow, purge engine, or retention framework before this lifecycle taxonomy is agreed.
**Approved narrow exception**: Spec 261 (`provider-missing-policy-visibility`) now captures the bounded policy-only provider-missing truth correction. Keep future lifecycle, deletion, retention, and purge work taxonomy-first; do not generalize Spec 261 into the broader lifecycle model.
**Spec candidate**: `Workspace, Tenant & Managed Object Lifecycle Governance v1` in `docs/product/spec-candidates.md`.
### Platform Operations Maturity

View File

@ -4,12 +4,12 @@ # Spec Candidates
> **Last reviewed:** 2026-04-30
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
> **Scoped maintenance:** 2026-05-01 provider-missing / Spec 261 alignment only; the active queue was not fully re-audited end-to-end.
>
> Repo-based next-spec queue for TenantPilot.
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
**Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
> **Last reviewed**: 2026-04-30
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
---
@ -420,7 +420,7 @@ ### Workspace, Tenant & Managed Object Lifecycle Governance v1
4. `Retention & Purge Governance v1` — retention periods, legal hold, purge eligibility, irreversible deletion confirmation, and audit trail.
5. `Restoreability Expiry & Evidence Retention v1` — distinguish restorable backup payloads from retained evidence/audit metadata and define when restore is no longer possible but evidence remains retained.
- **Roadmap fit**: This is not a P0 sales feature. It is a P2 enterprise trust and compliance hardening candidate that becomes important before serious production customer offboarding, destructive data operations, or regulated retention commitments. It must not block Customer Review Workspace Productization, Governance Decision Surface Convergence, or Cross-Tenant Compare & Promotion.
- **Candidate decision**: Keep as strategic candidate. Do not let near-term policy fixes expand into a general ghost-policy, deletion, or retention model before the lifecycle taxonomy is agreed. The bounded policy-only exception is now Spec 261 (`provider-missing-policy-visibility`); keep that spec isolated to provider-missing truth and restore continuity rather than treating it as partial completion of this broader taxonomy.
- **Candidate decision**: Keep as strategic candidate. Do not implement a narrow Ghost Policy spec until the lifecycle taxonomy is agreed. If provider-missing policy behavior becomes an immediate product bug, create a smaller follow-up spec named `Provider-Missing Policy Visibility & Restore Continuity v1`; that smaller spec must use `provider_deleted_at`, `missing_from_provider_at`, or an equivalent provider-presence field and must not use Laravel `SoftDeletes` or local deletion semantics.
- `Workspace-level PII override for review packs`: bounded deferred follow-up from Spec 109.
- `CSV export for filtered run metadata`: valid system-console follow-up, but not near the top of the queue.
@ -443,7 +443,6 @@ ## Promoted to Spec
- Private AI Execution & Policy Foundation -> Spec 248 (`private-ai-policy-foundation`)
- Customer Review Workspace v1 -> Spec 249 (`customer-review-workspace`)
- Decision-Based Governance Inbox v1 -> Spec 250 (`decision-governance-inbox`)
- Provider-Missing Policy Visibility & Restore Continuity v1 -> Spec 261 (`provider-missing-policy-visibility`)
- Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`)
- Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`)
- Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`)

View File

@ -1,66 +0,0 @@
# Preparation Review Checklist: Compliance Evidence Mapping v1
**Purpose**: Validate repo-fit preparation quality after `spec.md`, `plan.md`, and `tasks.md` are complete
**Reviewed**: 2026-04-30
**Feature**: [spec.md](../spec.md)
**Supporting artifacts**: [plan.md](../plan.md), [research.md](../research.md), [data-model.md](../data-model.md), [quickstart.md](../quickstart.md), [tasks.md](../tasks.md), [compliance-evidence-mapping.openapi.yaml](../contracts/compliance-evidence-mapping.openapi.yaml)
**Related standards**: [List Surface Review Checklist](../../../docs/product/standards/list-surface-review-checklist.md)
## Candidate Fit
- [x] The selected candidate still matches the active `Compliance Evidence Mapping v1` entry in `docs/product/spec-candidates.md`, the sequencing in `docs/product/roadmap.md`, and the moat blocker wording in `docs/product/implementation-ledger.md`
- [x] Existing `specs/` coverage was checked so this package stays a new follow-up rather than duplicating Specs 249 through 258
- [x] The scope stays on one bounded interpretation overlay over existing canonical-control and review truth instead of reopening control foundations or packaging work
- [x] Governance-as-a-Service Packaging and framework-specific overlays are explicitly deferred rather than hidden inside this slice
## Constitution Fit
- [x] The package stays on the existing Filament v5 plus Livewire v4 admin plane and does not introduce panel or provider-registration work beyond the current `bootstrap/providers.php` truth
- [x] No new persistence table, no new report engine, no OperationRun workflow, no portal shell, and no destructive action surface are introduced
- [x] Workspace and tenant isolation remain explicit, including `404` for non-members and out-of-scope tenant targets and capability gating only on reused secondary evidence paths
- [x] One dominant safe action per changed surface is explicitly described, with workspace list and detail disclosure roles remaining consistent across spec, plan, and tasks
- [x] Global-search safety is preserved without introducing a new searchable resource or widening review/evidence discovery across tenant boundaries
- [x] Asset strategy remains unchanged; if later implementation unexpectedly registers assets, deployment still uses the existing `cd apps/platform && php artisan filament:assets` step
## Surface Guardrails
- [x] The package references and satisfies the repo's [List Surface Review Checklist](../../../docs/product/standards/list-surface-review-checklist.md) for the customer review workspace list surface
- [x] The customer review workspace remains the primary decision surface with one dominant `Open released review` path and no competing list-row proof action
- [x] The released-review detail surface remains explanation-first, read-only in customer-workspace mode, and keeps supporting evidence as explicit in-body drilldown
- [x] No page-local control taxonomy, framework naming, or second interpretation path is introduced across the changed surfaces
## Artifact Consistency
- [x] `spec.md`, `plan.md`, `tasks.md`, `data-model.md`, and the conceptual contract all target the same shared `control_interpretation` contract and the same workspace plus released-review detail flow
- [x] The primary released-review detail route now follows the same `404` posture described in the spec, with explicit `403` handling reserved only for gated secondary evidence routes
- [x] The workspace contract now models only entitled tenants with a released review, while the no-released-review case remains a page-level empty state instead of a parallel row model
- [x] The required prep artifact `checklists/requirements.md` exists and includes explicit review outcome and workflow outcome fields
- [x] The required `.specify/scripts/bash/update-agent-context.sh copilot` step is recorded as completed during planning
## Test Governance
- [x] Validation lanes remain explicitly bounded to `confidence` plus one existing `browser` smoke
- [x] The package reuses existing `TenantReview`, `CustomerReviewWorkspace`, and evidence proof test families instead of creating a new heavy-governance or browser family
- [x] Reviewer proof commands remain explicit and minimal for the touched workspace, detail, evidence, and audit surfaces
- [x] The package includes explicit close-out handling for global-search safety, shared-interpretation-path consistency, and audit-metadata reuse
## Notes
- Reviewed after `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `tasks.md`, and the conceptual contract were aligned on 2026-04-30.
- This repository's preparation artifacts are intentionally implementation-oriented, so concrete routes, classes, list-surface standards, and validation commands are expected rather than treated as leakage.
- Implementation completed on 2026-04-30. The implementation keeps one shared `control_interpretation` contract, reuses existing audit events, preserves global-search disablement, and keeps the customer review workspace list surface released-review-only with one dominant inspect action.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Outcome**: `keep`
- **Reason**: The package keeps the new semantic layer bounded to one versioned interpretation overlay, records the list-surface guardrail expectations, aligns primary-route access semantics to the repo's `404` posture, and removes the extra no-review row branch so the implementation target stays narrow.
- **Workflow result**: Implemented and validated after the Spec Kit implementation loop.
## Implementation Review Outcome
- **Guardrail / Smoke Coverage**: PASS. Focused feature/browser tests and adjacent contract tests passed; Pint passed.
- **Shared interpretation path**: PASS. Composition writes one stored v1 interpretation; workspace and detail read it.
- **Audit metadata reuse**: PASS. Existing events carry `source_surface`, `review_id` where applicable, `tenant_filter_id`, and `interpretation_version`; no new event family was introduced.
- **Global-search safety**: PASS. Tenant review, review pack, and evidence resources remain globally disabled.
- **Residual risks**: none confirmed in scope after the implementation loop.

View File

@ -1,292 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Compliance Evidence Mapping v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the Compliance Evidence Mapping v1 planning package.
These paths describe existing Filament admin and tenant-scoped routes reused by
the implementation. The schemas document the shared interpretation contract the
feature is expected to add to existing review payloads; they do not define a new
public REST API.
servers:
- url: /
paths:
/admin/reviews/workspace:
get:
summary: View the compliance evidence mapping workspace
description: |
Existing admin-plane customer review workspace page reused as the primary
decision surface for mapped control readiness summaries. The route remains
read-only and tenant-safe.
parameters:
- in: query
name: tenant
required: false
schema:
type: string
description: |
Optional tenant prefilter using the existing tenant id or external id
pattern already accepted by the workspace page.
responses:
'200':
description: Workspace page rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/CustomerReviewWorkspacePageModel'
'404':
description: Not found for non-members, actors without entitled tenants, or explicit out-of-scope tenant targeting
/admin/t/{tenant}/reviews/{review}:
get:
summary: Open the mapped control explanation for a released review
description: |
Existing tenant-scoped released-review detail route reused as the secondary
context surface from the customer review workspace. The customer-workspace
flow uses the existing `customer_workspace=1` query flag to keep the detail
read-only and customer-safe.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: review
required: true
schema:
type: integer
- in: query
name: customer_workspace
required: false
schema:
type: boolean
description: Existing query-context flag that suppresses operator lifecycle actions on the detail surface.
responses:
'200':
description: Released review detail rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/CustomerReviewDetailModel'
'404':
description: Not found for non-members, tenant mismatches, or out-of-scope review targets
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
get:
summary: Open supporting evidence from a mapped control explanation
description: |
Existing tenant-scoped evidence detail route reused only after explicit
drilldown from the released-review detail surface and existing capability
checks.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: evidenceSnapshot
required: true
schema:
type: integer
- in: query
name: source_surface
required: false
schema:
type: string
description: Existing source-surface metadata hook reused by the shared audit path.
responses:
'200':
description: Evidence proof detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an in-scope actor missing the evidence capability
'404':
description: Not found for non-members, mismatched tenant scope, or unavailable evidence targets
components:
schemas:
ControlInterpretationVersion:
type: object
required:
- version_key
- display_label
- non_certification_disclosure
properties:
version_key:
type: string
example: compliance_evidence_mapping.v1
display_label:
type: string
non_certification_disclosure:
type: string
AccessState:
type: object
required:
- state
- message
properties:
state:
type: string
enum:
- available
- absent
- unavailable
- expired
- redacted
- partial
message:
type: string
url:
type: string
nullable: true
CustomerControlSummary:
type: object
required:
- control_key
- control_name
- readiness_bucket
- limitation_flags
- customer_summary
- recommended_next_action
properties:
control_key:
type: string
control_name:
type: string
domain_key:
type: string
nullable: true
readiness_bucket:
type: string
enum:
- follow_up_required
- review_recommended
- evidence_on_record
limitation_flags:
type: array
items:
type: string
enum:
- accepted_risk_influenced
- partial_mapping
- stale_evidence
- supporting_evidence_unavailable
- unmapped
customer_summary:
type: string
evidence_basis_summary:
type: string
nullable: true
accepted_risk_summary:
type: string
nullable: true
recommended_next_action:
type: string
detail_anchor:
type: string
nullable: true
CustomerControlExplanation:
allOf:
- $ref: '#/components/schemas/CustomerControlSummary'
- type: object
properties:
explanation_text:
type: string
evidence_basis_items:
type: array
items:
type: string
proof_access:
$ref: '#/components/schemas/AccessState'
CustomerReviewWorkspaceEntry:
type: object
required:
- tenant_id
- tenant_name
- latest_published_review_id
- latest_review_published_at
- interpretation
- control_summaries
properties:
tenant_id:
type: integer
tenant_name:
type: string
latest_published_review_id:
type: integer
latest_review_published_at:
type: string
format: date-time
interpretation:
$ref: '#/components/schemas/ControlInterpretationVersion'
control_summaries:
type: array
items:
$ref: '#/components/schemas/CustomerControlSummary'
follow_up_summary:
type: string
nullable: true
CustomerReviewWorkspacePageModel:
type: object
required:
- workspace_id
- entries
properties:
workspace_id:
type: integer
tenant_filter_id:
type: integer
nullable: true
entries:
type: array
items:
$ref: '#/components/schemas/CustomerReviewWorkspaceEntry'
empty_state_message:
type: string
nullable: true
CustomerReviewDetailModel:
type: object
required:
- review_id
- tenant_id
- customer_workspace_context
- interpretation
- controls
- operator_actions_hidden
properties:
review_id:
type: integer
tenant_id:
type: integer
customer_workspace_context:
type: boolean
interpretation:
$ref: '#/components/schemas/ControlInterpretationVersion'
controls:
type: array
items:
$ref: '#/components/schemas/CustomerControlExplanation'
operator_actions_hidden:
type: boolean
supporting_evidence_collapsed_by_default:
type: boolean
raw_support_details_hidden_by_default:
type: boolean

View File

@ -1,341 +0,0 @@
# Data Model — Compliance Evidence Mapping v1
**Spec**: [spec.md](spec.md)
No new persisted table, report artifact family, or projection store is required for this feature. The implementation reuses current review, section, evidence, finding, accepted-risk, membership, and audit truth, then embeds one bounded interpretation contract inside the existing review payloads.
## Source Truth Reused
### Workspace / Tenant Entitlement Context
**Purpose**: Establish the active workspace boundary and entitled tenant set before any workspace row, released-review detail, or supporting evidence route is resolved.
**Persisted carriers**:
- existing workspace membership rows
- existing tenant membership pivot rows and role assignments
- existing capability registry and role-capability map
**Relevant fields / contracts**:
- `workspace_id`
- `tenant_id`
- workspace membership existence
- tenant membership role
- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php)
- remembered tenant context from the existing workspace session model
**Validation rules**:
- the actor must be a member of the current workspace or the request resolves as not found
- workspace rows and explicit tenant filters may only resolve for entitled tenants in that workspace
- out-of-scope tenant targets remain `404` and must not leak review or evidence presence
### CanonicalControlDefinition
**Purpose**: Existing provider-neutral control identity used as the anchor for every mapped customer-safe control summary.
**Carrier**: existing [../../apps/platform/config/canonical_controls.php](../../apps/platform/config/canonical_controls.php) loaded through [../../apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php)
**Relevant fields**:
- `control_key`
- `name`
- `domain_key`
- `subdomain_key`
- `summary`
- `control_class`
- `evaluation_strategy`
- `evidence_archetypes`
- `historical_status`
**Validation / usage rules**:
- canonical controls remain provider-neutral and customer-facing labels must continue to use the catalog's neutral names
- provider-specific Microsoft bindings stay internal resolution inputs only
- the interpretation overlay may add customer-safe readiness meaning, but it must not create a second control taxonomy
### Findings Summary Evidence Input
**Purpose**: Existing per-finding evidence basis that already resolves canonical control references and accepted-risk governance state.
**Persisted carriers**:
- existing `evidence_snapshots`
- existing `evidence_snapshot_items`
- existing `findings`
**Primary producer**: [../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php](../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php)
**Relevant fields / payload**:
- `EvidenceSnapshot.id`
- `EvidenceSnapshot.generated_at`
- `EvidenceSnapshot.expires_at`
- `EvidenceSnapshotItem.dimension_key = findings_summary`
- `summary_payload.entries[].id`
- `summary_payload.entries[].status`
- `summary_payload.entries[].severity`
- `summary_payload.entries[].terminal_outcome`
- `summary_payload.entries[].canonical_control_resolution`
- `summary_payload.entries[].governance_state`
- `summary_payload.entries[].governance_warning`
- `summary_payload.canonical_controls[]`
- `summary_payload.risk_acceptance`
**Validation / usage rules**:
- canonical control identity continues to come from the current upstream resolution path only
- the interpretation overlay should consume current governance-state and terminal-outcome values rather than inventing a second status source
- if the current payload is sufficient, upstream evidence collection remains unchanged
### FindingException / Accepted Risk Decision
**Purpose**: Existing accepted-risk and accountability truth that can weaken or qualify a customer-safe control interpretation.
**Persisted carrier**: existing `finding_exceptions` rows via [../../apps/platform/app/Models/FindingException.php](../../apps/platform/app/Models/FindingException.php)
**Relevant fields / relationships**:
- `status`
- `current_validity_state`
- `owner_user_id`
- `approved_by_user_id`
- `request_reason`
- `review_due_at`
- `effective_from`
- `expires_at`
- `owner`
- `approver`
- `currentDecision`
**Validation / usage rules**:
- accepted-risk truth may qualify a control interpretation but must not collapse into a fully positive readiness claim
- missing owner, approver, or review-due truth must surface as explicit partial disclosure rather than fabricated certainty
- this slice remains read-only and does not introduce approval, renewal, or revocation actions
### TenantReview
**Purpose**: Existing released review artifact that anchors the shared interpretation contract for both workspace and detail surfaces.
**Persisted carrier**: existing `tenant_reviews` rows via [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php)
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `generated_at`
- `published_at`
- `summary`
- `evidence_snapshot_id`
- `current_export_review_pack_id`
- `operation_run_id`
- `tenant`
- `evidenceSnapshot`
- `sections`
**Planned embedded summary additions inside the existing `summary` JSON**:
- `control_interpretation.version`
- `control_interpretation.display_label`
- `control_interpretation.non_certification_disclosure`
- `control_interpretation.controls[]`
- `control_interpretation.limitations[]`
**Validation / usage rules**:
- only released reviews feed the default customer-safe workspace path
- the shared interpretation version must be carried at compose time so older released reviews preserve the meaning shown at publication time
- the workspace must read the embedded summary contract instead of recomputing the interpretation locally
### TenantReviewSection
**Purpose**: Existing detailed review section carrier used for the per-control explanation surface on the released review detail page.
**Persisted carrier**: existing `tenant_review_sections` rows via [../../apps/platform/app/Models/TenantReviewSection.php](../../apps/platform/app/Models/TenantReviewSection.php)
**Relevant fields**:
- `section_key`
- `title`
- `sort_order`
- `completeness_state`
- `summary_payload`
- `render_payload`
- `measured_at`
**Planned usage**:
- add one new v1 interpretation section inside the existing section family with the canonical `control_interpretation` section key
- keep the section near the top of the customer-workspace detail disclosure so mapped-control explanation precedes rawer supporting sections
**Planned embedded payload shape**:
- `summary_payload.version`
- `summary_payload.mapped_control_count`
- `summary_payload.follow_up_required_count`
- `summary_payload.limitation_counts`
- `render_payload.entries[]` containing detailed per-control explanations
**Validation / usage rules**:
- exactly one interpretation section should exist per review for v1
- the detail surface should read this section instead of deriving a second explanation contract in-page
- this remains part of the existing review artifact and does not create a new section table or entity family
### EvidenceSnapshot
**Purpose**: Existing supporting proof artifact that informs evidence-basis summaries and explicit deeper drilldown.
**Persisted carrier**: existing `evidence_snapshots` rows via [../../apps/platform/app/Models/EvidenceSnapshot.php](../../apps/platform/app/Models/EvidenceSnapshot.php)
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `completeness_state`
- `generated_at`
- `expires_at`
- `summary`
- `items`
**Validation / usage rules**:
- supporting proof remains optional, lower-priority, and capability-gated
- the overlay may summarize the evidence basis, but raw payloads and unrestricted diagnostics remain outside the default customer-safe path
- supporting evidence access should reuse the existing evidence route and audit action
### Audit Log Event Family
**Purpose**: Existing audit trail used to keep the displayed interpretation and access path traceable.
**Persisted carrier**: existing `audit_logs` rows via [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php)
**Relevant current action IDs**:
- `customer_review_workspace.opened`
- `tenant_review.opened`
- `evidence_snapshot.opened`
**Planned shared metadata additions**:
- `source_surface`
- `interpretation_version`
- `review_id` when a released review is opened from the workspace
- `tenant_filter_id` when the workspace is entered with a safe prefilter
**Validation / usage rules**:
- no new audit store or audit event family is justified
- interpretation-version traceability should be added to shared metadata before introducing any new event concept
## Embedded Interpretation Contracts
### ControlInterpretationOverlayVersion
**Purpose**: The explicit version label that states which customer-safe interpretation rules produced the displayed control/readiness summaries.
**Persistence**: embedded in existing `TenantReview.summary` and the existing interpretation section payload; echoed in shared audit metadata when relevant
**Fields**:
- `version_key` (planned baseline: `compliance_evidence_mapping.v1`)
- `display_label`
- `non_certification_disclosure`
**Validation rules**:
- exactly one version key is carried for a given released review
- the version must be visible on workspace and detail surfaces whenever mapped control summaries are shown
### CustomerControlSummary
**Purpose**: Compact per-control summary reused by the workspace and as the top-level contract for the detail surface.
**Persistence**: embedded inside `TenantReview.summary['control_interpretation']['controls']`
**Fields**:
- `control_key`
- `control_name`
- `domain_key`
- `readiness_bucket`
- `limitation_flags[]`
- `customer_summary`
- `evidence_basis_summary`
- `accepted_risk_summary` (nullable)
- `recommended_next_action`
- `detail_anchor` (nullable)
- `supporting_finding_ids[]`
**Validation rules**:
- one summary exists per mapped canonical control for the released review
- the summary must stay customer-safe and must not expose provider IDs, raw JSON, or support-only diagnostics
- the same summary meaning must appear on the workspace and detail surfaces for the same released review
### CustomerControlExplanation
**Purpose**: Detailed per-control explanation rendered on the released-review detail surface.
**Persistence**: embedded inside the interpretation `TenantReviewSection.render_payload.entries[]`
**Fields**:
- `control_key`
- `control_name`
- `readiness_bucket`
- `limitation_flags[]`
- `explanation_text`
- `evidence_basis_items[]`
- `accepted_risk_context` (nullable)
- `recommended_next_action`
- `proof_access_state`
**Validation rules**:
- the detail explanation must be a denser expansion of the compact summary, not a second source of truth
- supporting evidence remains explicit drilldown, not default-visible raw detail
- the explanation must keep non-certification language visible and must not claim legal or framework attestation
### CustomerReviewWorkspaceEntry
**Purpose**: Derived row-level presentation contract for one entitled tenant with a released review on the workspace page.
**Persistence**: none; derived at request time from the latest released `TenantReview` and its embedded interpretation contract
**Fields**:
- `workspace_id`
- `tenant_id`
- `tenant_name`
- `latest_published_review_id`
- `latest_review_published_at`
- `interpretation_version`
- `control_summaries[]`
- `follow_up_summary`
**Validation rules**:
- exactly one derived entry exists per entitled tenant with a released review visible in the current workspace scope
- when no entitled tenant has a released review, the workspace falls back to a page-level empty state rather than emitting partial row contracts without a review
- the one dominant row action remains opening the released review; supporting proof stays secondary and is not a peer primary action on the row
### CustomerReviewDetailContext
**Purpose**: Derived detail-page contract for the existing released-review surface when launched from the workspace.
**Persistence**: none; derived from the released `TenantReview`, its interpretation section, and the current `customer_workspace` query flag
**Fields**:
- `review_id`
- `tenant_id`
- `customer_workspace_context` (boolean)
- `interpretation_version`
- `controls[]`
- `non_certification_disclosure`
- `operator_actions_hidden` (boolean)
- `supporting_evidence_collapsed_by_default` (boolean)
**Validation rules**:
- the detail page must stay read-only in customer-workspace context
- the same interpretation version and control meaning shown on the workspace must be visible here
- supporting evidence should remain explicit in-body drilldown rather than a new dominant header action
## Derived Disclosure States
This feature introduces no new platform-wide lifecycle family. It does require one bounded overlay-local readiness contract and explicit limitation flags.
### Primary readiness buckets
- `follow_up_required`
- `review_recommended`
- `evidence_on_record`
### Limitation flags
- `accepted_risk_influenced`
- `partial_mapping`
- `stale_evidence`
- `supporting_evidence_unavailable`
- `unmapped`
**Validation rules**:
- limitation flags qualify the customer-safe interpretation and must never be silently collapsed into `evidence_on_record`
- accepted risk remains a qualifier, not a passing state
- these values stay embedded in the interpretation contract only and do not become a broader platform taxonomy or standalone persistence family

View File

@ -1,308 +0,0 @@
# Implementation Plan: Compliance Evidence Mapping v1
**Branch**: `259-compliance-evidence-mapping` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
## Summary
Add one bounded, versioned interpretation overlay over the canonical control references already flowing through tenant review composition, then reuse that same overlay in the existing [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) page and the existing released-review detail flow anchored by [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php). The narrow implementation path is to derive the customer-safe control meaning once from existing review, finding, accepted-risk, and evidence truth, carry the versioned result inside the existing `TenantReview` summary and section payloads, and render those same payloads on both surfaces.
This remains an admin-plane Filament v5 surface running on Livewire v4. Provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) with no change expected. No new panel, provider, OperationRun flow, destructive action, persistence table, report engine, portal, global-search scope, or asset strategy change is planned. Governance-as-a-Service Packaging and framework-specific overlays remain explicitly deferred.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams
**Storage**: PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `evidence_snapshots`, `evidence_snapshot_items`, `findings`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence table planned
**Testing**: Pest v4 feature coverage plus one bounded browser smoke on the existing customer review workspace flow
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel monolith in `apps/platform`, existing `/admin` workspace surface plus existing tenant-scoped `/admin/t/{tenant}` detail and proof reuse
**Project Type**: Web application (Laravel monolith with Filament pages and resources)
**Performance Goals**: derive the overlay from already-composed review/evidence truth, keep render-time queries tenant-safe and eager-loaded, avoid new Graph calls, and avoid new queue starts during page render
**Constraints**: one bounded versioned overlay only; no new table; no parallel report engine; no panel/provider change; no OperationRun UX; no destructive or authoring actions; no global-search expansion; no asset strategy change; no Governance-as-a-Service packaging or framework-specific overlay work
**Scale/Scope**: one shared overlay version, one existing workspace page, one existing released-review detail flow, existing evidence proof routes, existing DE/EN localization files, existing audit metadata, and focused expansion of the current review/evidence/browser tests
## Likely Affected Repo Surfaces
- [../../apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php) and [../../apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php) as the existing provider-neutral control identity, name, summary, and evidence metadata reused by the overlay.
- [../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php) and [../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php](../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php) as the only current control-resolution path feeding review evidence; this feature should not create a second resolver.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php) to derive one customer-safe control interpretation section inside the existing review section family.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php) to carry `interpretation_version` plus compact mapped-control summaries into the existing `TenantReview.summary` payload so workspace and detail surfaces stay aligned.
- [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) and [../../apps/platform/app/Models/TenantReviewSection.php](../../apps/platform/app/Models/TenantReviewSection.php) for narrow helper access to the stored overlay contract without adding a new persisted entity family.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) to keep workspace access on the current latest-published and tenant-entitlement seams.
- [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) and [../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php](../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php) for mapped-control summary rows, interpretation-version disclosure, safe tenant-prefilter reuse, and one dominant `Open released review` path.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php) and [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) for the detailed per-control explanation path, customer-workspace read-only mode, and in-body supporting-evidence placement.
- [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) and existing evidence detail pages only as reused supporting-proof routes when the actor explicitly drills deeper and has the current capability.
- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for shared audit metadata tying workspace entry, review open, and evidence open events back to the interpretation version.
- [../../apps/platform/lang/en/localization.php](../../apps/platform/lang/en/localization.php) and [../../apps/platform/lang/de/localization.php](../../apps/platform/lang/de/localization.php) for localization-ready customer-safe wording and non-certification disclosure.
- [../../apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php), [../../apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php), [../../apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php), [../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php](../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php), [../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php](../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php), and [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) for the bounded proof surface already in the repo.
## Shared Interpretation Path
- Keep canonical control resolution exactly where it already happens today: upstream in evidence collection via [../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php](../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php) and [../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php). The UI must not grow a second control-resolution path.
- Introduce one fixed v1 interpretation helper adjacent to the governance controls or tenant-review composition seams, not a generic registry or framework engine. Its only job is to turn existing canonical control references plus review, finding, accepted-risk, and evidence truth into one customer-safe control summary contract with a version label.
- Persist the shared result inside existing review truth so both surfaces read the same meaning: a compact control-summary block in `TenantReview.summary` for workspace consumption and one detailed control-explanation section in `TenantReviewSection` for released-review detail.
- Keep the overlay version explicit inside that shared payload and reuse it in audit metadata when the actor opens the workspace, opens a released review from that workspace, or drills into supporting evidence.
- Preserve one dominant inspect path. The workspace remains scan-first and opens the released review. Supporting evidence or export affordances must not compete with that path and should stay secondary, preferably inside the detail surface rather than as a second peer action on the list.
## UI / Filament & Livewire Fit
- Keep [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) as the primary decision surface. No new page, Resource, cluster, panel, portal, or provider is introduced.
- Keep the existing customer-workspace query-flag path on [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) as the secondary context surface. It should deepen the mapped control meaning without reopening operator lifecycle controls.
- Preserve Filament v5 plus Livewire v4 admin-plane patterns. Provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) with no change expected.
- Preserve one dominant next action. On the workspace that remains `Open released review`. On the released-review detail surface, the mapped-control explanation is primary and supporting evidence stays in-body and capability-gated rather than becoming a new competing header action.
- Keep all touched resources non-search-expanding. `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` are already globally disabled, and this feature does not change that posture.
- No destructive actions are introduced. If existing non-customer detail actions are touched to preserve the customer-workspace mode, they remain outside this slice and continue to rely on `->action(...)`, `->requiresConfirmation()`, and existing authorization when used in their normal operator context.
## RBAC / Policy Fit
- Workspace membership remains the first isolation boundary through [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) and current workspace context.
- Tenant entitlement remains capability-first and reuse-only. This slice does not add a new role family, raw capability strings, or customer-only policy branch.
- Non-members and out-of-scope tenant targets remain `404`. Inside an established scope, optional secondary evidence routes may still return explicit capability denial when the actor lacks the supporting evidence capability.
- The workspace continues to show only the latest published review per entitled tenant. Internal-only, draft, or ready-but-unreleased reviews remain outside the default customer-safe path.
- Supporting evidence drilldown stays on existing tenant-scoped evidence routes and current policy enforcement. The mapped control overlay does not widen discovery or expose new artifact classes.
## Audit / Logging Fit
- Reuse [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) and [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) as the only audit path.
- Existing `customer_review_workspace.opened`, `tenant_review.opened`, and `evidence_snapshot.opened` action IDs are sufficient for the current slice. The narrow follow-up, if needed, is metadata enrichment rather than a new event family.
- Planned shared audit metadata should include the source surface, active tenant prefilter when present, review identifier when relevant, and the interpretation version shown to the actor so FR-014 remains traceable without a new audit store.
- Passive render should stay quiet. Audit boundaries remain explicit workspace entry, explicit released-review open, and explicit evidence open.
## Data & Query Fit
- Keep canonical control references anchored to existing `findings_summary` evidence entries and [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php)`::canonicalControlReferences()` rather than recomputing control identity in page code.
- Add the customer-safe interpretation as one bounded embedded contract inside existing review payloads. The likely shape is a compact `summary['control_interpretation']` block for workspace use plus one detailed `TenantReviewSection` entry for released-review detail.
- Carry the version identifier with the shared payload at compose time so older published reviews preserve the interpretation version that produced them. Avoid render-time reinterpretation drift between workspace and detail surfaces.
- Keep limitation states explicit but bounded. `unmapped`, `partial`, `stale`, `unavailable`, and `accepted-risk-influenced` should remain overlay-local derived semantics, not a new platform-wide lifecycle/state framework.
- Keep supporting proof anchored to existing `EvidenceSnapshot` truth and current evidence routes. The overlay should summarize the evidence basis, not create a second proof model.
- Avoid touching [../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php](../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php) unless implementation proves a missing normalized field blocks a single shared interpretation pass. The default plan is to consume its current canonical-control and governance-state output as-is.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: status messaging, review summaries, detail disclosure, evidence viewers, recommendation phrasing, and navigation entry points
- **State layers in scope**: page, detail, URL-query, table/session restore
- **Audience modes in scope**: customer/read-only, customer-admin, auditor-read-only, operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: collapsed by default and capability-gated on reused evidence/detail routes only
- **One-primary-action / duplicate-truth control**: the workspace keeps one dominant `Open released review` path, the detail page stays explanation-first, and the mapped control meaning is authored once in the shared overlay contract rather than duplicated in page-local mappers
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract, bounded-browser-smoke
- **Exception path and spread control**: none planned; any proposal for a page-local mapper, new portal surface, or framework-specific overlay becomes exception-required drift
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `CanonicalControlCatalog`, `CanonicalControlResolver`, `FindingsSummarySource`, `TenantReviewSectionFactory`, `TenantReviewComposer`, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, `EvidenceSnapshotResource`, `WorkspaceAuditLogger`, `AuditActionId`, and review localization copy
- **Shared abstractions reused**: canonical control definitions and resolver output, existing review summary and section payloads, existing workspace and tenant-scoped routes, current capability checks, and the shared audit logger
- **New abstraction introduced? why?**: one bounded v1 interpretation helper is expected because current repo seams identify canonical controls but do not yet produce a customer-safe, versioned control/readiness contract reusable across surfaces
- **Why the existing abstraction was sufficient or insufficient**: existing seams already provide the released review truth, control references, accepted-risk signals, evidence basis, and UI surfaces; what is missing is one shared interpretation contract, not a new persistence or routing foundation
- **Bounded deviation / spread control**: keep the interpretation helper fixed to one overlay version and current review surfaces; packaging and framework-specific overlays are explicitly deferred to later specs
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: read-only workspace, detail, and evidence rendering 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-specific subject binding and signal resolution inside [../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php) and [../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php](../../apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php)
- **Platform-core seams**: interpretation version, customer-safe control readiness wording, evidence-basis phrasing, accepted-risk influence wording, recommendation phrasing, and audit metadata tying the shown interpretation back to the released review
- **Neutral platform terms / contracts preserved**: `canonical control`, `control readiness`, `evidence basis`, `accepted risk`, `recommendation`, and `interpretation version`
- **Retained provider-specific semantics and why**: provider-specific Microsoft bindings remain internal input truth only because they already exist upstream in canonical control resolution; they do not become customer-facing labels
- **Bounded extraction or follow-up path**: `document-in-feature` for the bounded v1 overlay; `follow-up-spec` for Governance-as-a-Service packaging or framework-specific overlays if later required
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The slice derives from existing evidence snapshots and released review truth only.
- Read/write separation: PASS. No new mutation, publication, regeneration, remediation, or destructive action is introduced.
- Graph contract path: PASS. No new Graph work, provider calls, or contract registry changes are part of this preparation slice.
- Deterministic capabilities: PASS. Existing capability registries and role maps remain authoritative.
- Workspace and tenant isolation: PASS. Workspace membership and tenant entitlement remain `404` boundaries.
- RBAC-UX plane separation: PASS. All changes remain in the existing `/admin` plane and current tenant-scoped detail/proof routes.
- Destructive confirmation standard: PASS by non-use. No destructive action is planned for the customer-safe path.
- Global search safety: PASS. `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` are already globally disabled, and no new searchable surface is introduced.
- OperationRun / Ops-UX: PASS by non-use. The feature starts no runs and changes no run lifecycle UX.
- Data minimization: PASS. Raw payloads, provider IDs, and support-only diagnostics remain hidden by default and only appear through existing gated drilldown.
- Test governance (TEST-GOV-001): PASS. Planned proof stays inside focused feature files plus one bounded browser smoke.
- Proportionality / no premature abstraction: PASS with constraint. One bounded v1 interpretation helper is justified because the spec requires a shared versioned meaning layer; a generic registry or multi-framework engine is explicitly out of bounds.
- Persisted truth (PERSIST-001): PASS. No new table or artifact family is planned; the versioned overlay is carried inside existing `TenantReview` and `TenantReviewSection` payloads only.
- Behavioral state (STATE-001): PASS. Readiness buckets and limitation markers stay overlay-local and serve customer-safe decision support; they are not elevated into a cross-domain lifecycle framework.
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing Filament pages and review/evidence seams remain the default path, and the shared interpretation contract prevents page-local drift.
- Provider boundary (PROV-001): PASS. Provider-specific semantics stay inside existing resolution inputs and do not leak into customer-facing platform-core vocabulary.
- Filament / Laravel planning contract: PASS. Filament v5 stays on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel/provider work is planned, no global-search expansion is planned, and asset handling remains unchanged.
- Explicit deferrals: PASS. Governance-as-a-Service Packaging and framework-specific overlays remain out of scope for this version.
**Gate evaluation**: PASS.
- The narrow path is defensible if the implementation derives the interpretation once during review composition and keeps both UI surfaces on that shared payload.
- The plan fails the gate if it drifts into page-local mappers, a new packaging/reporting engine, or a multi-framework taxonomy.
**Post-design re-check**: PASS once [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), and [contracts/compliance-evidence-mapping.openapi.yaml](contracts/compliance-evidence-mapping.openapi.yaml) are present and the agent-context update step is executed.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for review composition, workspace summary, detail explanation, authorization, navigation context, and evidence drilldown; Browser for one bounded workspace-to-detail smoke
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the existing feature families already cover review composition, customer review workspace behavior, and detail-surface contracts; one existing browser smoke is enough to catch rendered disclosure regressions without creating a new browser family
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing tenant review, evidence snapshot, finding, finding-exception, workspace membership, and readonly-actor fixtures
- **Expensive defaults or shared helper growth introduced?**: no; new helper use should stay explicit and local to the tenant-review and review-workspace family
- **Heavy-family additions, promotions, or visibility changes**: none beyond the already-existing single browser smoke
- **Surface-class relief / special coverage rule**: standard-native-filament relief on the workspace page, shared-detail-family coverage on released-review explanation and evidence drilldown
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the same interpretation version and control meaning appear on both surfaces, verify `404` isolation for out-of-scope tenant targets, verify capability-gated evidence drilldown, and verify non-certification wording stays visible
- **Budget / baseline / trend follow-up**: none expected beyond small feature-local additions in existing suites
- **Review-stop questions**: lane fit, hidden fixture growth, browser sprawl, page-local mapper drift, and framework-scope creep
- **Escalation path**: `document-in-feature` for contained metadata or helper-shape notes; `reject-or-split` for any drift into packaging, framework-specific overlays, or a second interpretation path
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: this package is already the bounded follow-up spec for the customer review productization lane; broader packaging and framework overlay work are explicitly deferred
## Project Structure
### Documentation (this feature)
```text
specs/259-compliance-evidence-mapping/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── compliance-evidence-mapping.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Reviews/
│ │ │ └── CustomerReviewWorkspace.php
│ │ └── Resources/
│ │ ├── TenantReviewResource.php
│ │ ├── TenantReviewResource/Pages/ViewTenantReview.php
│ │ └── EvidenceSnapshotResource.php
│ ├── Models/
│ │ ├── TenantReview.php
│ │ ├── TenantReviewSection.php
│ │ ├── EvidenceSnapshot.php
│ │ ├── EvidenceSnapshotItem.php
│ │ └── FindingException.php
│ ├── Services/
│ │ ├── Audit/WorkspaceAuditLogger.php
│ │ ├── Evidence/Sources/FindingsSummarySource.php
│ │ └── TenantReviews/
│ │ ├── TenantReviewComposer.php
│ │ ├── TenantReviewRegisterService.php
│ │ └── TenantReviewSectionFactory.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ └── Governance/Controls/
│ │ ├── CanonicalControlCatalog.php
│ │ ├── CanonicalControlDefinition.php
│ │ └── CanonicalControlResolver.php
├── config/canonical_controls.php
├── lang/
│ ├── de/localization.php
│ └── en/localization.php
├── bootstrap/providers.php
└── tests/
├── Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php
├── Feature/Reviews/
└── Feature/TenantReview/
```
**Structure Decision**: Laravel monolith. The implementation stays inside the existing `apps/platform` governance, review, evidence, localization, and audit seams, with no new panel/provider location and no new persistence layer.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One bounded v1 interpretation helper and embedded payload shape | The spec requires one shared, versioned control/readiness meaning reused across workspace and detail surfaces | Page-local copy or two separate mappers would drift across surfaces and would not keep older released reviews traceable to one interpretation version |
## Proportionality Review
- **Current operator problem**: customer reviewers still have to translate canonical control references, findings, accepted-risk details, and evidence snapshots into a control/readiness story by hand.
- **Existing structure is insufficient because**: current catalog and resolver identify controls, but they do not yet produce one customer-safe, versioned interpretation contract reusable across workspace and detail surfaces.
- **Narrowest correct implementation**: introduce one fixed v1 interpretation helper, persist the derived result inside existing `TenantReview` summary and section payloads, and render that same payload on the existing workspace and released-review detail surfaces.
- **Ownership cost created**: maintain one overlay version label, one bounded readiness vocabulary, one detailed control explanation section, localized customer-safe copy, and focused review/evidence tests.
- **Alternative intentionally rejected**: page-local mappers, a multi-framework overlay engine, Governance-as-a-Service packaging, and a new reporting or portal surface were rejected because they import broad permanent complexity before one shared interpretation path is proven.
- **Release truth**: current-release truth. This is the next bounded follow-up after customer review productization, not speculative future infrastructure.
## Phase 0 — Research (output: research.md)
Research resolves the remaining implementation-shaping decisions:
- keep the existing customer review workspace and released-review detail routes as the only primary and secondary surfaces
- derive the interpretation once during tenant review composition and reuse it through existing review payloads instead of page-local mappers
- reuse canonical control catalog metadata and current findings-summary resolution rather than adding a second control taxonomy
- carry the version label through existing review and audit truth so older released reviews remain traceable
- keep supporting evidence on existing evidence routes and current capability checks
- keep Governance-as-a-Service packaging and framework-specific overlays explicitly deferred
- keep validation inside the current review/evidence feature families plus the single existing browser smoke
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow interpretation shape:
- no new table, cache, or report artifact family; the overlay lives inside existing `TenantReview` and `TenantReviewSection` payloads only
- one compact workspace contract plus one detailed released-review explanation contract document how the same mapped control meaning is reused across surfaces
- one conceptual OpenAPI file documents the existing workspace, released-review detail, and supporting evidence routes that consume the shared overlay
- quickstart records the intended implementation order, validation commands, Filament v5 plus Livewire v4 posture, unchanged provider-registration location, and explicit deferrals
- `.specify/scripts/bash/update-agent-context.sh copilot` must run after the design artifacts are generated, even if it results in no technology changes
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/compliance-evidence-mapping.openapi.yaml](contracts/compliance-evidence-mapping.openapi.yaml)
- [quickstart.md](quickstart.md)
- Required agent-context update executed during planning on 2026-04-30 via `.specify/scripts/bash/update-agent-context.sh copilot`, refreshing `.github/agents/copilot-instructions.md` as the repo workflow expects.
## Phase 2 — Planning (for tasks.md)
Dependency-ordered outline for the later `tasks.md` step:
1. Define one fixed v1 interpretation helper near the canonical control or tenant-review composition seams, using existing control references, finding outcomes, accepted-risk truth, and evidence basis only.
2. Extend `TenantReviewSectionFactory` and `TenantReviewComposer` to persist `interpretation_version`, compact control summaries, and detailed per-control explanations into existing review payloads and sections.
3. Update `CustomerReviewWorkspace` and its Blade intro to read the shared overlay contract, show interpretation-version and non-certification disclosure, preserve tenant-safe prefilter behavior, and keep `Open released review` as the one dominant action.
4. Update the released-review detail surface to explain the same mapped control meaning, keep customer-workspace mode read-only, and move supporting evidence access into explicit in-body drilldown rather than competing primary actions.
5. Reuse existing evidence routes and audit events, enriching shared metadata with interpretation-version context where needed.
6. Expand the focused review, workspace, detail, evidence, and smoke tests without introducing a new browser family or a second interpretation path.
## Planning Guardrail Notes
- Planning guardrail result: PASS. Filament remains v5 on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel/provider is expected, no global-search expansion is planned, no destructive action is introduced, and no new asset bundle is planned.
- Shared-path result: the plan keeps one interpretation contract reused across composition, workspace, and detail surfaces rather than duplicating logic in local mappers.
- Explicit deferral result: Governance-as-a-Service Packaging and framework-specific overlays remain follow-up specs and are not folded into this package.
- Preparation workflow result: the required agent-context refresh step has already been completed for this package and does not need a follow-on prep task.
## Implementation Close-Out
- Guardrail / Smoke Coverage result: PASS on 2026-04-30. The implementation stayed on Filament v5 plus Livewire v4, did not add a panel/provider/path, did not add assets, did not add persistence, did not add OperationRun behavior, and did not add destructive actions.
- Shared-interpretation-path outcome: PASS. `ComplianceEvidenceMappingV1` derives one stored `control_interpretation` contract during review composition; the workspace, released-review detail, and supporting evidence links read that stored payload instead of remapping controls locally.
- Audit-metadata reuse outcome: PASS. Existing `customer_review_workspace.opened`, `tenant_review.opened`, `evidence_snapshot.opened`, and review-pack download audit paths carry `source_surface`, `review_id` where applicable, `tenant_filter_id`, and `interpretation_version`; no new audit event family was required.
- Global-search safety outcome: PASS. `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` remain globally disabled; no searchable resource was added.
- List-surface review outcome: PASS. The customer review workspace shows entitled tenants with released reviews only, keeps no-released-review as a page-level empty state, shows visible version and non-certification disclosure, and keeps `Open released review` as the only row action.
- Document-in-feature decision: T023 closed as metadata reuse at existing call sites. `WorkspaceAuditLogger` and `AuditActionId` did not need code changes because the existing actions already cover the required audit moments.
- Follow-up-spec decisions: none required for this v1 implementation. Governance-as-a-Service packaging and framework-specific overlays remain explicitly deferred from this spec.

View File

@ -1,57 +0,0 @@
# Quickstart — Compliance Evidence Mapping v1
## Preconditions
- Docker is running and the Sail stack for `apps/platform` is available.
- The feature stays inside the existing Laravel monolith and existing admin plane.
- Filament remains v5 on Livewire v4.
- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); no provider or panel change is part of this work.
- No new persistence table, no new OperationRun flow, no new portal shell, no new report engine, no global-search expansion, and no asset strategy change are in scope.
- Governance-as-a-Service Packaging and framework-specific overlays remain deferred.
## Intended Implementation Order
1. Review the current canonical-control, findings-summary, tenant-review composition, workspace, detail, evidence, and feature-test seams so the change stays on one shared path.
2. Add one fixed v1 interpretation helper near the canonical control or tenant-review composition seams. Keep it single-purpose and versioned instead of building a generic overlay registry.
3. Extend [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php) and [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php) to embed `interpretation_version`, compact customer control summaries, and one detailed control-explanation section into the existing review payloads.
4. Add narrow access helpers on [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) if needed so both surfaces can read the same embedded contract without re-deriving it.
5. Update [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) and its Blade intro to render interpretation-version disclosure, customer-safe control summaries, explicit limitation states, and one dominant `Open released review` action.
6. Update [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php) and [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) so the released-review detail explains the same mapped control meaning, stays read-only in customer-workspace mode, and keeps supporting evidence as capability-gated in-body drilldown.
7. Reuse existing evidence routes and shared audit events, enriching metadata with interpretation-version context where needed instead of inventing a new audit concept.
8. Update existing DE/EN localization keys for customer-safe wording and explicit non-certification disclosure.
9. Expand only the existing review, workspace, detail, evidence, and smoke tests.
10. Run the targeted tests and Pint after implementation.
## Targeted Validation Commands (after implementation)
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Planned Smoke Checklist (after implementation)
1. Sign in to `/admin` as a readonly-capable actor with workspace scope and open `/admin/reviews/workspace`.
2. Confirm only entitled tenants appear and that the default-visible path uses released reviews only.
3. Confirm the workspace shows the interpretation version, non-certification disclosure, control summaries, limitation states, and one dominant `Open released review` path.
4. Open a released review and confirm the same interpretation version and mapped control meaning appear on the detail surface.
5. Confirm raw payloads, provider IDs, fingerprints, and support-only diagnostics remain hidden by default in customer-workspace mode.
6. Drill into supporting evidence and confirm the route is capability-gated, tenant-safe, and still tied back to the customer-review flow.
7. Attempt an explicit out-of-scope tenant target and confirm the response remains not found without leaking tenant or review presence.
## Notes
- Implementation close-out on 2026-04-30: the package is implemented in the existing review, evidence, audit, localization, and test seams without adding new persistence, assets, providers, panels, OperationRun behavior, or destructive actions.
- Filament remains v5 on Livewire v4.
- Provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) with no change expected.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) remain globally disabled; this slice does not change their search posture.
- No destructive, authoring, publishing, generation, or remediation action belongs on the customer-safe mapped-control path.
- No new Filament assets are expected. If future implementation unexpectedly registers assets, deployment still requires `cd apps/platform && php artisan filament:assets`, but this package does not plan such a change.
- Governance-as-a-Service Packaging and framework-specific overlays stay outside this spec and should not be folded into implementation tasks for v1.
## Implementation Validation Results
- Focused review/evidence/browser regression: `./vendor/bin/sail artisan test --compact tests/Unit/TenantReview/TenantReviewComposerTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` passed with 51 tests and 323 assertions.
- Adjacent contract regression: `./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php` passed with 43 tests and 225 assertions.
- Formatting: `./vendor/bin/sail bin pint --dirty --format agent` passed.
- Browser smoke path: tenant review detail → `Open customer workspace` → released-review workspace row → `Open latest review` → customer-workspace review detail, with no browser console or JavaScript errors.

View File

@ -1,153 +0,0 @@
# Research — Compliance Evidence Mapping v1
**Date**: 2026-04-30
**Spec**: [spec.md](spec.md)
This document resolves the planning decisions for the smallest safe versioned interpretation overlay over the repo's existing canonical control references and released review truth.
## Decision 1 — Keep the existing customer review workspace and released-review detail flow as the only primary and secondary surfaces
**Decision**: Reuse [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) as the primary decision surface and [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) under the existing `customer_workspace` query flag as the only secondary context surface.
**Rationale**:
- The repo already has the admin-plane route, tenant-safe prefilter behavior, customer-workspace query context, and bounded feature and browser tests for these surfaces.
- The missing piece is shared interpretation, not new routing, a new panel, or a portal shell.
- Reusing the existing surfaces keeps the feature aligned with the authoritative spec and avoids a second customer-review UX vocabulary.
**Alternatives considered**:
- Add a dedicated compliance portal or a second customer-only page.
- Rejected: duplicates the current review-consumption path and widens scope into shell-level IA.
- Push all explanation into the workspace only.
- Rejected: would leave the first drilldown without the detailed control explanation the spec requires.
## Decision 2 — Derive the interpretation once during tenant review composition and reuse it through existing review payloads
**Decision**: Materialize the mapped control/readiness contract during tenant review composition by extending [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php) and [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php), then reuse that stored result in both workspace and detail surfaces.
**Rationale**:
- The spec requires the same mapped control meaning and interpretation version to remain consistent between workspace and released-review detail.
- Carrying the result inside existing `TenantReview` and `TenantReviewSection` payloads preserves traceability for older released reviews without a new persistence table.
- A single compose-time pass is the narrowest way to prevent page-level drift.
**Alternatives considered**:
- Compute the interpretation separately in each page.
- Rejected: two local mappers would drift and would not preserve older review history against the version used at release time.
- Add a new projection table or report artifact for the overlay.
- Rejected: violates the no-new-persistence constraint.
## Decision 3 — Keep canonical control identity on the existing catalog and resolver seams
**Decision**: Reuse [../../apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php), [../../apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php), and [../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php](../../apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php) as the only control identity and resolution path.
**Rationale**:
- The catalog already exposes provider-neutral control keys, names, summaries, and domain metadata suitable for customer-safe labels.
- The resolver already maps Microsoft-owned evidence signals onto canonical controls upstream in evidence collection.
- The feature needs an interpretation overlay over canonical controls, not a second control taxonomy or framework registry.
**Alternatives considered**:
- Add a second customer-facing control catalog.
- Rejected: duplicates platform-core control truth and increases drift risk.
- Move provider-specific signal mapping into the UI.
- Rejected: deepens provider coupling in the wrong layer and violates the one-shared-path requirement.
## Decision 4 — Carry one explicit version label inside existing review truth and audit metadata
**Decision**: Use one explicit overlay version key, carried inside existing review payloads and surfaced anywhere the mapped control/readiness view appears. The planning baseline is a single v1 key such as `compliance_evidence_mapping.v1`.
**Rationale**:
- FR-003 and FR-014 require the interpretation version to be visible and traceable.
- Embedding the version into current review payloads preserves what an older released review meant at the time it was composed.
- The existing audit pipeline can carry the same version context without a new audit store.
**Alternatives considered**:
- Recompute the current version at render time only.
- Rejected: older released reviews could silently change meaning after later rule updates.
- Add a dedicated database column or separate version-history model.
- Rejected: broader persistence change than the current release truth needs.
## Decision 5 — Use a bounded readiness contract with explicit limitation flags instead of pass/fail or certification language
**Decision**: Keep the customer-safe interpretation contract bounded to a small readiness vocabulary plus explicit limitation flags. The planned primary readiness buckets are:
- `follow_up_required`
- `review_recommended`
- `evidence_on_record`
The planned limitation flags are:
- `accepted_risk_influenced`
- `partial_mapping`
- `stale_evidence`
- `supporting_evidence_unavailable`
- `unmapped`
**Rationale**:
- The feature must help a customer decide what needs follow-up without implying formal certification or legal compliance.
- A small readiness vocabulary with explicit limitation modifiers is easier to localize, easier to test, and less likely to overstate the evidence basis than a large scoring system.
- Keeping accepted risk as a modifier preserves the distinction between a governed exception and a fully positive readiness claim.
**Alternatives considered**:
- Use compliant / non-compliant / certified language.
- Rejected: overstates what the product can legitimately claim.
- Add a larger scorecard or framework-specific status ladder.
- Rejected: imports framework-engine complexity that the spec explicitly defers.
## Decision 6 — Keep supporting proof on the existing evidence routes and current capability checks
**Decision**: Reuse [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) and existing evidence detail pages as the only supporting-proof routes for this slice.
**Rationale**:
- Existing evidence resources already enforce tenant scope, capability checks, and audit semantics.
- The spec calls for deeper proof only after explicit drilldown, not a new proof viewer or raw payload surface.
- Keeping proof access secondary preserves one dominant workspace action and avoids export/package redesign.
**Alternatives considered**:
- Add a new customer-only proof viewer.
- Rejected: duplicates evidence routing and widens scope.
- Put raw proof details directly on the workspace row.
- Rejected: violates the decision-first disclosure hierarchy.
## Decision 7 — Reuse existing audit action IDs and enrich metadata before creating new audit concepts
**Decision**: Keep the audit path on [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) and [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php), reusing `customer_review_workspace.opened`, `tenant_review.opened`, and `evidence_snapshot.opened` with interpretation-version metadata where needed.
**Rationale**:
- The required access moments already exist as auditable actions in the repo.
- The remaining requirement is traceability of the interpretation version and source surface, which fits shared metadata better than a new event family.
- Reusing the current audit path preserves consistent redaction and workspace or tenant context handling.
**Alternatives considered**:
- Create a new compliance-evidence audit stream.
- Rejected: unnecessary persistence and ownership cost for a read-only slice.
- Leave version context unaudited.
- Rejected: weakens FR-014 traceability.
## Decision 8 — Keep validation inside the current review and evidence feature families plus the single existing browser smoke
**Decision**: Expand the existing review and evidence feature tests and keep [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) as the only browser proof.
**Rationale**:
- The repo already has narrow feature files for canonical control references, workspace behavior, detail explanation, authorization, navigation context, and evidence routes.
- Those files are the cheapest honest proof for isolation, consistent wording, and cross-surface reuse.
- One bounded smoke is enough to catch rendered disclosure regressions without creating a new heavy test family.
**Alternatives considered**:
- Add a broader browser suite for every control state and evidence path.
- Rejected: too expensive for the bounded value of this slice.
- Rely only on unit tests around a new helper.
- Rejected: feature behavior and tenant-safe disclosure still need surface-level proof.
## Decision 9 — Keep Governance-as-a-Service Packaging and framework-specific overlays explicitly deferred
**Decision**: Treat this package as the foundational shared interpretation contract only. Governance-as-a-Service Packaging and framework-specific overlays remain follow-up work after the v1 overlay is stable.
**Rationale**:
- The spec explicitly defers both areas.
- Packaging work should consume a proven shared interpretation contract rather than shaping it prematurely.
- Framework-specific overlays would force broader taxonomy and copy decisions before one customer-safe path is validated.
**Alternatives considered**:
- Fold packaging-ready exports into this feature.
- Rejected: broadens scope into report-system territory.
- Add BSI, NIS2, CIS, or ISO-specific overlay semantics now.
- Rejected: imports a framework engine before the base interpretation path is proven.

View File

@ -1,349 +0,0 @@
# Feature Specification: Compliance Evidence Mapping v1
**Feature Branch**: `259-compliance-evidence-mapping`
**Created**: 2026-04-30
**Status**: Draft
**Input**: User description: "Compliance Evidence Mapping v1 as the smallest versioned overlay that maps existing technical governance truth into one customer-safe control/readiness view and one reuse path in the released review or export flow, without certification claims, new control foundations, or a parallel reporting engine."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot already has repo-real canonical controls, evidence snapshots, findings, accepted-risk governance, and customer-review surfaces, but the product still lacks one explicit customer-safe layer that turns existing technical governance truth into a control/readiness story a customer can understand without operator translation.
- **Today's failure**: Operators and customer-facing reviewers still have to translate raw findings, evidence, and accepted-risk details into control meaning by hand. That makes the product harder to sell, easier to overstate, and less trustworthy than the existing repo truth.
- **User-visible improvement**: An authorized reviewer can open the existing customer review flow and understand which controls need attention, what evidence supports that view, what risk is accepted, and what next action is recommended without seeing raw diagnostics or certification-style claims.
- **Smallest enterprise-capable version**: Add one versioned interpretation overlay over existing canonical control references and render it in the current customer review workspace plus released review detail flow so the same customer-safe control/readiness meaning appears in both surfaces.
- **Explicit non-goals**: No certification claims, no legal/compliance guarantees, no BSI/NIS2/CIS/ISO-specific framework engine, no new provider-specific control model, no new portal or panel, no new persistence table, no new report engine, no review authoring or mutation flow, no destructive actions, and no broad export-suite redesign.
- **Permanent complexity imported**: One bounded versioned control-interpretation overlay, one shared customer-safe control/readiness vocabulary, one shared summary contract reused across current review surfaces, and focused review/evidence test expansion. No new persisted entity, no new panel, and no multi-framework taxonomy are introduced in v1.
- **Why now**: The roadmap and implementation ledger both keep compliance evidence mapping as the next open moat-building follow-up after customer review productization, and `specs/258-customer-review-productization/spec.md` explicitly defers this slice.
- **Why not local**: A page-local copy rewrite would not keep workspace and detail flows consistent, would not make interpretation version explicit, and would not provide a reusable customer-safe meaning layer over canonical control references.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New semantic layer, new versioned interpretation vocabulary, and review-surface reuse concerns. Defense: the slice is bounded to one overlay family and one current review path, derives from existing truth, and explicitly defers framework-specific and packaging follow-ups.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- existing admin-plane customer review workspace at `/admin/reviews/workspace`
- existing tenant-scoped released review detail route for the selected review
- existing evidence summary/proof routes reached from released review context when the actor is entitled
- **Data Ownership**: All control/readiness truth remains derived from existing tenant-owned review, finding, accepted-risk, and evidence records plus the existing canonical control catalog. Existing review payloads may carry derived interpretation version and control summary data, but no new workspace-owned or tenant-owned table, projection store, or standalone artifact family is introduced.
- **RBAC**:
- this remains an admin-plane follow-up, not a new panel or identity surface
- workspace membership remains the first isolation boundary
- page entry requires an established workspace scope plus at least one entitled tenant the actor may read through the existing capability registry
- evidence pointers and deeper proof paths remain capability-gated through current review and evidence authorization paths
- non-members or out-of-scope tenant requests resolve as deny-as-not-found
- no new customer-only role family or raw capability strings are introduced
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from tenant-scoped review context, the workspace prefilters to that tenant and foregrounds the latest released review for that tenant. Without incoming tenant context, the page shows only entitled tenants in the current workspace.
- **Explicit entitlement checks preventing cross-tenant leakage**: Aggregated lists, control summaries, review detail entry, and evidence pointers only resolve for tenants the actor is entitled to in the current workspace. Inaccessible tenant targets are omitted from aggregated lists and resolve as not found when directly targeted.
## 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)**: status messaging, review summaries, detail disclosure, evidence/report viewers, recommendation phrasing, and safe evidence-link affordances
- **Systems touched**: existing `CanonicalControlCatalog`, existing `CanonicalControlResolver`, existing tenant review composition, existing `CustomerReviewWorkspace`, existing released review detail surfaces, and existing evidence-summary/proof presentation
- **Existing pattern(s) to extend**: current canonical control references already exposed through tenant review composition and the customer review productization flow from Spec 258
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalControlCatalog`, `CanonicalControlResolver`, `TenantReviewSectionFactory`, `TenantReviewComposer`, and the existing customer review workspace/detail surfaces
- **Why the existing shared path is sufficient or insufficient**: The repo already has canonical control references flowing into review composition, but it does not yet have one explicit customer-safe interpretation layer over those references. The missing piece is shared interpretation, not new data foundations.
- **Allowed deviation and why**: none. The feature must add one shared interpretation path rather than a second page-local control vocabulary.
- **Consistency impact**: Control labels, readiness wording, evidence-basis language, recommended action phrasing, accepted-risk influence, and interpretation-version disclosure must stay aligned between workspace summary and released review detail.
- **Review focus**: Reviewers must block any page-local control taxonomy, direct framework naming, or raw technical status mapping that bypasses the shared interpretation layer.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: This slice stays on read-only review and evidence interpretation surfaces and does not add a new run start, queue, resume, or completion path.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: platform-core
- **Seams affected**: canonical control interpretation, review-summary payloads, customer-safe labels, readiness wording, and evidence-link semantics
- **Neutral platform terms preserved or introduced**: canonical control, control readiness, evidence basis, recommendation, interpretation version
- **Provider-specific semantics retained and why**: Existing Microsoft subject bindings remain internal resolution inputs only because the current canonical control catalog already depends on them. They must not become customer-visible control language.
- **Why this does not deepen provider coupling accidentally**: Customer-facing summaries stay keyed to canonical control keys and released review truth, not provider object IDs, Graph-specific nouns, or framework-specific policy names.
- **Follow-up path**: framework-specific overlays and management/export packaging remain follow-up specs after this shared customer-safe layer is stable
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Customer Review Workspace page | yes | Native Filament page plus shared review/evidence primitives | status messaging, review summaries, evidence-link affordances | page, table/filter, disclosure state | no | Existing page gains one control/readiness view rather than a new navigation surface |
| Released Customer Review detail | yes | Native Filament resource/detail surface plus shared review/evidence primitives | detail disclosure, evidence summaries, recommendation framing | detail sections, disclosure state | no | Existing detail flow becomes the explanation surface for the mapped control/readiness view |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | Primary Decision Surface | Customer reviewer, customer admin, or auditor decides which controls need follow-up and whether the released review is sufficient for the current conversation | control/readiness summary, top evidence basis, accepted-risk influence, and recommended next action | released review detail and entitled evidence pointers after explicit open | Primary because it is the first calm customer-safe review route and should answer the top-level control question without forcing raw technical reconstruction | Follows review-consumption workflow, not storage-object navigation | Replaces manual translation of findings into control meaning across multiple pages |
| Released Customer Review detail | Secondary Context Surface | Reader validates why a control is in its current state and what evidence or accepted-risk context supports that interpretation | per-control explanation, evidence basis, accepted-risk context, and next recommendation | deeper proof context only after explicit drilldown and capability checks | Not primary because it deepens the summary chosen in the workspace rather than replacing it | Keeps the workflow centered on one released review after the overview step | Prevents the first page from carrying both high-level decision support and full technical explanation |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | customer-read-only, customer-admin, auditor-read-only, operator-MSP | which controls need attention, what evidence basis exists, what risk is accepted, and what next step is recommended | release timing and secondary freshness/context only after opening the review | raw payloads, provider IDs, and unrestricted diagnostics stay out of the default path | `Open released review` | raw/support detail and deeper proof context remain hidden or capability-gated | Workspace summary states the mapped control meaning once; the detail surface explains it rather than restating the same summary blocks |
| Released Customer Review detail | customer-read-only, customer-admin, auditor-read-only, operator-MSP | per-control readiness meaning, linked findings/evidence basis, accepted-risk influence, interpretation version, and next recommendation | secondary lineage and deeper evidence detail only in follow-on sections | raw payloads, provider-debug data, and unrestricted evidence internals remain hidden or gated | `Review supporting evidence` | support-only detail and deep diagnostics remain outside the default customer-safe view | Detail expands the chosen control state and version; it does not recreate the workspace overview as a separate source of truth |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the released review for the selected tenant | full-row open to released review detail | required | evidence and proof links stay inside the detail flow, not peer row actions | none | /admin/reviews/workspace | /admin/t/{tenant}/reviews/{record} | workspace context, tenant prefilter, interpretation version, control readiness | Customer review | which controls need attention, why, and what next action is recommended | none |
| Released Customer Review detail | Detail / Report / Evidence | Read-only detail report | Review supporting evidence for a surfaced control | sectioned detail page with in-body drilldown | forbidden | in-body evidence pointers only after the mapped control explanation | none | /admin/reviews/workspace | /admin/t/{tenant}/reviews/{record} | workspace, tenant, released review, interpretation version, evidence basis | Customer review | why this control is in its current state and what evidence supports it | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | Customer reviewer, customer admin, or auditor with read access | Decide which control areas need follow-up conversation and which are sufficiently evidenced for the current review cycle | Read-only workspace review overview | Which control areas matter now, what evidence supports them, and what should happen next? | mapped control states, evidence basis summary, accepted-risk influence, and next recommendation | release lineage, deeper evidence detail, and secondary diagnostics only after drilldown | control readiness, evidence completeness/freshness, accepted-risk influence, release state | none | Open released review | none |
| Released Customer Review detail | Customer reviewer, customer admin, or auditor with read access | Understand why a mapped control has its current state and what evidence or accepted-risk context supports that view | Read-only detail report | Why does this control read this way, and what supports or limits that interpretation? | per-control explanation, linked findings, evidence basis, accepted-risk context, interpretation version, and next recommendation | deeper proof metadata and support-only diagnostics | control readiness, evidence sufficiency, accepted-risk timing, interpretation version | none | Review supporting evidence | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: yes
- **Current operator problem**: Existing customer review truth already references canonical controls, but customers still consume technical findings and evidence fragments without one bounded customer-safe control/readiness meaning layer.
- **Existing structure is insufficient because**: Raw canonical control references alone do not explain customer-safe readiness meaning, evidence sufficiency, accepted-risk influence, or next action, and page-local copy would drift across workspace and detail surfaces.
- **Narrowest correct implementation**: Introduce exactly one versioned interpretation overlay and use it in one current review path so the same mapped control meaning appears in the workspace summary and released review detail without adding a framework engine or new persistence.
- **Ownership cost**: Maintain one interpretation catalog/version, one customer-safe readiness vocabulary, one shared summary contract, and focused review/evidence tests that prove consistency and non-overstatement.
- **Alternative intentionally rejected**: A copy-only local rewrite was rejected because it would not make the interpretation version explicit or reusable. A multi-framework compliance engine was rejected because it would import broad permanent complexity before a single customer-safe overlay is proven.
- **Release truth**: current-release moat and sellability blocker, not future-release preparation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: Focused feature tests are the narrowest sufficient proof for control-reference reuse, customer-safe disclosure, interpretation-version visibility, no-certification wording, tenant isolation, and evidence-link gating. One bounded browser smoke remains justified because the slice materially changes default-visible decision content on an existing review surface.
- **New or expanded test families**: expand the existing `Reviews/CustomerReviewWorkspace` and `TenantReview` feature families; keep exactly one bounded browser smoke around the customer review workspace flow
- **Fixture / helper cost impact**: low to moderate. Reuse existing tenant review evidence builders, canonical control references, released review fixtures, findings, accepted-risk truth, and evidence snapshot helpers rather than introducing new provider or queue-heavy defaults.
- **Heavy-family visibility / justification**: exactly one browser smoke stays explicit because this slice is about customer-safe wording and disclosure on a real rendered page. No broader heavy-governance family is introduced.
- **Special surface test profile**: shared-detail-family
- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for routing, authorization, interpretation consistency, and partial/unmapped states; the existing bounded smoke remains the only required browser proof.
- **Reviewer handoff**: Reviewers must confirm that the same control/readiness meaning appears in workspace and detail views, that interpretation version is explicit, that no surface implies certification or legal attestation, that unauthorized tenant targets leak nothing, and that deeper proof remains capability-gated.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
## Scope Boundaries
### In Scope
- one versioned interpretation overlay over existing canonical control references already present in review composition
- customer-safe control/readiness summaries in the existing customer review workspace and released review detail flow
- per-control linkage to existing findings, evidence basis, accepted-risk context, and recommended next action
- explicit interpretation-version disclosure wherever the mapped control/readiness view appears
- explicit separation between technical findings and customer-safe interpretation so the product does not overstate compliance claims
- explicit partial, unmapped, stale, unavailable, or accepted-risk-influenced states when the evidence basis does not support a stronger claim
- reuse of existing entitlement, redaction, localization, review, evidence, and audit foundations
- one shared interpretation contract reused across the current review surfaces so future review/export work can build on the same meaning rather than retranslate it
### Non-Goals
- certification claims or legal/regulatory guarantees
- hard-coded BSI, NIS2, CIS, ISO, or similar framework semantics in platform core
- multiple overlay families, customer profiles, or framework-specific scorecards in v1
- a parallel reporting engine, new review-pack format, or recurring governance-package system
- new persistence tables, new review publication lifecycle, or a separate compliance artifact family
- new panel, portal, customer shell, or identity plane
- raw evidence payload viewers, provider-debug views, or support-only diagnostics in the customer-safe default path
- remediation, authoring, publication, generation, or other write paths
- a new global-searchable resource or widened cross-tenant discovery behavior
## Dependencies
- existing `CanonicalControlCatalog` and `CanonicalControlResolver`
- existing canonical control references already surfaced through tenant review composition
- existing `TenantReviewSectionFactory` and `TenantReviewComposer`
- existing evidence snapshot, finding, and accepted-risk workflow truth
- existing `CustomerReviewWorkspace` and released review detail surfaces
- customer review productization foundations defined in `specs/258-customer-review-productization/spec.md`
- existing workspace and tenant RBAC plus evidence-link capability enforcement
## Assumptions
- Canonical control references already flowing through tenant review composition remain authoritative starting points for the first overlay.
- The current Filament v5 and Livewire v4 admin-plane customer review workspace remains the canonical entry surface for v1; no new provider registration or panel path is needed.
- One interpretation version identifier can be carried through existing review payloads and surfaces without inventing a new persistence family.
- Existing evidence snapshots already contain enough finding and accepted-risk truth to support one customer-safe control/readiness view.
- Existing panel assets are sufficient; this slice does not justify new global or on-demand asset registration.
- Review-pack packaging and framework-specific overlays can remain follow-up work once one customer-safe interpretation path is proven.
## Risks
- If the mapped control/readiness wording sounds too definitive, customers could misread the feature as legal attestation rather than product interpretation.
- Some released reviews may contain sparse canonical control coverage, forcing partial or unmapped states until more evidence references are present.
- If workspace and detail surfaces compute the interpretation separately, the same control could drift between surfaces; the implementation must preserve one shared meaning path.
- Over-eager implementation could smuggle in framework naming or export packaging changes because those follow-ups are adjacent; this spec must keep them deferred.
- If interpretation version is not visible and traceable, later review history could become harder to defend or compare safely.
## Candidate Selection Rationale
- **Selected candidate**: Compliance Evidence Mapping v1
- **Source locations**:
- `docs/product/spec-candidates.md` active P2 candidate
- `docs/product/roadmap.md` compliance moat / executive review follow-through ordering
- `docs/product/implementation-ledger.md` open gap `Compliance-oriented control mapping is not productized`
- `specs/258-customer-review-productization/spec.md` explicit deferral of this follow-up
- existing repo seams around canonical controls, tenant reviews, evidence snapshots, and the customer review workspace
- **Why selected**: This is the next unspecced candidate that both roadmap docs and repo truth support after the completed customer-review productization prep. It unlocks a reusable customer-safe interpretation layer that governance packaging depends on.
- **Why this is the smallest viable implementation slice**: The repo already has canonical control references, review composition, evidence truth, and the customer review workspace. The missing piece is one bounded customer-safe interpretation overlay over that truth, not a new control foundation or reporting engine.
- **Intentional narrowing from source candidate**: This slice deliberately limits itself to one overlay family and the current review surfaces. Multi-framework overlays, management packaging, and broader export presentation remain follow-up work.
- **Why close alternatives are deferred**:
- Governance-as-a-Service Packaging v1 remains deferred because it depends on this shared interpretation layer before it can package the meaning safely.
- External Support Desk / PSA Handoff is a separate commercialization lane and does not unblock the compliance-readiness story.
- Private AI Execution Governance Foundation is a later architecture lane and does not address the current customer-safe control/readiness gap.
## Follow-up Candidates
- Governance-as-a-Service Packaging v1 once this customer-safe interpretation layer can be reused in one management-ready package
- framework-specific overlays only after one shared canonical interpretation path is proven and bounded
- review-pack or export reuse of the same interpretation contract once the current review-surface meaning is stable
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Understand control readiness at a glance (Priority: P1)
An authorized customer reviewer wants the customer review workspace to explain which control areas currently need follow-up, what evidence basis exists, and what next action is recommended without manually translating raw findings.
**Why this priority**: This is the core moat and sellability gap. If the workspace still reads like raw technical findings, the feature fails.
**Independent Test**: Sign in as an entitled read-only actor, open the customer review workspace, and confirm that the latest released review for each visible tenant shows a customer-safe control/readiness summary with evidence basis and next recommendation.
**Acceptance Scenarios**:
1. **Given** an entitled actor has access to a tenant with a released review and canonical control references, **When** they open the customer review workspace, **Then** they see only entitled tenants and a customer-safe control/readiness summary for the current released review.
2. **Given** a surfaced control has linked findings, evidence, or accepted-risk context, **When** the actor scans the workspace summary, **Then** they can understand the control state, supporting evidence basis, and recommended next action without opening raw diagnostics.
3. **Given** a released review has no mapped canonical control references for the first overlay, **When** the actor opens the workspace, **Then** the surface shows an explicit partial or unmapped state rather than fabricating control coverage.
---
### User Story 2 - Understand why a control reads this way (Priority: P1)
An authorized customer reviewer wants the released review detail to explain why a mapped control has its current state so they can trust the interpretation and review the supporting evidence without seeing operator-only residue.
**Why this priority**: Productization is incomplete if the workspace summary is calm but the first drilldown still forces technical translation.
**Independent Test**: Open a released review from the workspace and verify that each surfaced control explains linked findings, evidence basis, accepted-risk influence, interpretation version, and next recommendation while keeping raw provider detail hidden by default.
**Acceptance Scenarios**:
1. **Given** a released review contains mapped control data, **When** the actor opens the released review detail, **Then** they see why each surfaced control has its current state through linked findings, evidence basis, accepted-risk context, and recommendation.
2. **Given** deeper proof context exists, **When** the actor stays on the default detail view, **Then** raw payloads, provider IDs, and support-only diagnostics remain hidden until explicit drilldown and capability checks.
3. **Given** the evidence basis is incomplete, stale, or partially mapped, **When** the actor reviews the control explanation, **Then** the state is clearly partial or limited rather than overstated as satisfied or compliant.
---
### User Story 3 - Trust the interpretation basis and its limits (Priority: P2)
An authorized customer reviewer wants to see which interpretation version they are reading and to understand that the product is surfacing technical governance truth rather than claiming formal certification.
**Why this priority**: Versioned interpretation and bounded claims are what make this layer auditable and safe to reuse later.
**Independent Test**: Open the workspace and released review detail, verify interpretation-version visibility and bounded claim language, and confirm that out-of-scope tenant or gated evidence requests do not leak additional truth.
**Acceptance Scenarios**:
1. **Given** a mapped control/readiness view is shown, **When** the actor views the workspace or released review detail, **Then** the interpretation version and a bounded non-certification disclosure are visible.
2. **Given** the actor targets a tenant outside their scope or a gated evidence link they cannot use, **When** they open that route, **Then** the system reveals no cross-tenant presence and only shows explicit denial or unavailability for in-scope secondary paths.
3. **Given** interpretation rules change in a later release, **When** an older released review is inspected, **Then** its surfaced control/readiness view still identifies the interpretation version that produced it.
### Edge Cases
- What happens when the released review has findings but no canonical control references for the first overlay? The customer-safe surface shows an explicit unmapped or partial state rather than inventing control coverage.
- What happens when a control has accepted-risk context that weakens the recommendation? The summary shows the accepted-risk influence and does not present the same state as an unaccepted open issue.
- What happens when evidence exists but is stale or incomplete? The control view remains explicit about the limited basis and does not collapse into a stronger readiness claim.
- What happens when a user enters through a saved filter or tenant-prefilter for an inaccessible tenant? The filter resolves safely without exposing the tenant or its mapped control state.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new write path, no new queue or scheduled work, and no new persistence table. It changes review-surface disclosure, shared interpretation semantics, and existing review payload content.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This follow-up must justify the new interpretation layer as the minimum shared semantic addition needed now, keep it versioned and bounded, and avoid speculative multi-framework modeling or new persistence.
**Constitution alignment (XCUT-001):** The feature must extend existing canonical control, review, evidence, accepted-risk, localization, and audit paths rather than invent a page-local compliance vocabulary.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Default-visible content must remain decision-first and customer-safe, with deeper evidence detail revealed only after explicit user intent.
**Constitution alignment (TEST-GOV-001):** The implementation must stay with focused feature coverage plus one bounded browser smoke and avoid creating a broader heavy family.
**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries. Optional secondary evidence access remains capability-gated inside an established scope. No new role strings are introduced by this spec.
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The slice must remain a native Filament read-only reporting flow with one dominant inspect action and no destructive or mutation actions.
### Functional Requirements
- **FR-001**: The system MUST keep this slice inside the existing admin-plane customer review workspace and released-review detail flow rather than creating a new panel, portal, or customer shell.
- **FR-002**: The system MUST derive every mapped control/readiness summary from existing canonical control references, findings, accepted-risk truth, evidence snapshots, and released review truth without creating a new persistence table or a parallel reporting engine.
- **FR-003**: The system MUST introduce one explicit versioned interpretation overlay for customer-safe control/readiness meaning and MUST identify that version anywhere the mapped control/readiness view is displayed.
- **FR-004**: The default workspace path MUST show only entitled tenants and only released or otherwise customer-safe reviews as the basis for the control/readiness view.
- **FR-005**: The default-visible workspace summary MUST answer which control areas need follow-up, what evidence basis exists, whether accepted risk changes the interpretation, and what next action is recommended.
- **FR-006**: Mapped control summaries MUST clearly separate technical findings from customer-safe interpretation and MUST NOT claim certification, legal compliance, or framework attestation.
- **FR-007**: The released review detail surface MUST explain why each surfaced control has its current state through linked findings, evidence basis, accepted-risk context, and recommendation.
- **FR-008**: The feature MUST make unmapped, incomplete, stale, unavailable, and accepted-risk-influenced states explicit and MUST NOT silently collapse them into passing or fully ready states.
- **FR-009**: Raw payloads, provider IDs, and support-only diagnostics MUST remain hidden by default and MUST be revealed only through explicit drilldown and existing capability checks.
- **FR-010**: The primary action from the workspace MUST remain opening the released review, and any secondary evidence or proof link MUST NOT compete with operator, mutation, or admin-only actions.
- **FR-011**: Interpretation labels and readiness wording MUST reuse shared canonical control and review semantics rather than page-local mappings.
- **FR-012**: When launched from tenant-scoped context, the workspace MUST preserve a safe tenant prefilter and return path without widening discovery beyond entitled tenants.
- **FR-013**: Non-members or out-of-scope workspace or tenant requests MUST resolve as deny-as-not-found, while actors inside an established scope MAY receive explicit capability denial only for gated secondary evidence paths.
- **FR-014**: The interpretation version and displayed control/readiness summaries MUST remain traceable through existing review and audit truth so later reviewers can identify which mapping produced a surfaced state.
- **FR-015**: The feature MUST NOT introduce a new global-searchable resource or broaden existing search discovery in a way that reveals review, control, or evidence artifacts across tenant boundaries.
- **FR-016**: Customer-facing labels and guidance introduced by this slice MUST remain localization-ready for the existing DE/EN product language posture.
- **FR-017**: The feature MUST expose no destructive, remediation, authoring, publishing, generation, or admin-only actions in the customer-safe mapped control/readiness path.
- **FR-018**: The same mapped control/readiness meaning for a released review MUST remain consistent between workspace summary and released review detail.
- **FR-019**: The feature MUST establish one shared interpretation contract reused across the current review surfaces so later review or export work can consume the same meaning without redefining it.
- **FR-020**: Framework-specific overlays, management packaging, and certification claims MUST remain explicitly out of scope for this version.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace | existing `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only | `recordUrl()` or full-row open to the released review | `Open released review` only | none | `Clear filters` only when filters are active; otherwise explanatory no-data text with no create CTA | `N/A` | `N/A` | yes | One primary inspect path keeps the mapped control summary scan-first and avoids parallel evidence-link clutter on the list surface |
| Released Customer Review detail | existing tenant-scoped released review detail surface | none by default | `N/A` | `N/A` | none | `N/A` | no new dominant header action; supporting evidence stays in-body and capability-gated | `N/A` | yes | The mapped control/readiness view is explanatory, not mutating; no destructive or generation action is introduced |
Action Surface Contract is satisfied for this slice. Each affected surface keeps one primary inspect/open model, no empty `ActionGroup` or `BulkActionGroup` placeholder, and no destructive-action placement rules are needed because destructive actions are out of scope. `UI-FIL-001` and `UX-001` are satisfied by staying inside native Filament read-only surfaces, using explicit empty states, and keeping the control/readiness emphasis aligned to shared review semantics rather than page-local visual language.
### Key Entities *(include if feature involves data)*
- **Control Interpretation Overlay Version**: The bounded version label that states which customer-safe interpretation rules produced the displayed control/readiness summaries.
- **Customer Control Summary**: A derived per-control summary for one released review that combines readiness meaning, evidence basis, accepted-risk influence, and next recommendation without becoming a new persisted entity family.
- **Canonical Control Reference**: The existing shared control reference already flowing through tenant review composition and anchoring which controls may be surfaced in the first overlay.
- **TenantReview**: The existing released review artifact that anchors the customer-safe review flow and detail context.
- **Finding**: The existing issue-level governance truth that feeds the control interpretation and recommendation framing.
- **Accepted Risk Decision**: The existing accepted-risk or exception truth that can change how a control state is presented to the customer.
- **EvidenceSnapshot**: The existing supporting proof artifact that informs evidence basis summaries and deeper proof drilldown.
- **AuditLog**: The existing audit trail used to keep interpretation access and version context traceable without introducing a new audit store.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An entitled read-only actor can answer which control areas need follow-up, why they read that way, and what next action is recommended from the current customer review flow in two interactions or fewer.
- **SC-002**: In 100% of validated customer-safe scenarios, the workspace and released-review detail surfaces show the same mapped control/readiness meaning and interpretation version for the same released review.
- **SC-003**: In 100% of validated unauthorized workspace, tenant, or gated-evidence scenarios, the feature reveals no cross-tenant control, review, or evidence presence.
- **SC-004**: In 100% of validated mapped-control scenarios, the surface clearly distinguishes technical governance truth from customer-safe interpretation and makes no certification or legal-attestation claim.
- **SC-005**: Reviews lacking sufficient mapped control data show an explicit partial or unmapped state rather than falsely implying complete coverage or readiness.

View File

@ -1,210 +0,0 @@
---
description: "Task list for Compliance Evidence Mapping v1"
---
# Tasks: Compliance Evidence Mapping v1
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/contracts/compliance-evidence-mapping.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/quickstart.md`
**Tests**: Required (Pest) for runtime behavior changes. Keep proof in the narrow `confidence` lane plus one bounded `browser` smoke because this slice changes review composition, workspace/detail disclosure, evidence-route reuse, and audit traceability on existing surfaces.
**Operations**: No new `OperationRun`, queue, remote call, destructive action, publication flow, generation flow, or background processing is introduced. Auditability stays on the existing shared audit pipeline only.
**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets remain `404`; in-scope actors may receive explicit denial or unavailable messaging only on the reused secondary evidence path. Reuse existing capability registries; do not add raw capability strings or role-string checks.
**Filament / Provider Safety**: Filament remains v5 on Livewire v4, panel providers remain registered through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`, no new panel/provider/path or asset strategy is introduced, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` plus `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` remain globally disabled.
**Shared Pattern Reuse**: Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` rather than introducing a second interpretation path, a new report engine, framework-specific overlays, or Governance-as-a-Service packaging scope.
**Organization**: Tasks are grouped by user story so shared interpretation composition, workspace rendering, released-review explanation, and evidence-route traceability remain independently testable after the common seams are settled.
## Test Governance Notes
- Lane assignment: `confidence` plus one explicit `browser` smoke remain the narrowest sufficient proof for shared interpretation reuse, customer-safe disclosure, tenant isolation, capability-gated evidence drilldown, and interpretation-version traceability.
- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspace*.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`; do not widen this slice into a new browser or export/report test family.
- Reuse existing released-review, finding, finding-exception, evidence snapshot, entitled-tenant, workspace membership, localization, and audit fixtures; any helper added during implementation must stay explicit and cheap by default.
- If implementation finds that current action IDs already cover the required audit moments, close the corresponding audit task as metadata enrichment only and record the outcome as `document-in-feature` instead of creating a new audit event family.
---
## Phase 1: Setup (Shared Context)
**Purpose**: Lock the bounded interpretation overlay scope, validation lanes, and exact repo seams before runtime edits begin.
- [x] T001 Review the bounded slice, non-goals, guardrail outcomes, and user stories in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/contracts/compliance-evidence-mapping.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/quickstart.md`
- [x] T002 [P] Review the shared implementation seams in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/{en,de}/localization.php`
- [x] T003 [P] Confirm the focused validation commands and existing proof families in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Settle the one shared interpretation contract and baseline surface guardrails before any user story-specific rendering work begins.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` to lock the bounded `control_interpretation` contract, version-key persistence, limitation flags, and reuse of canonical control references from existing review truth
- [x] T005 Create the fixed overlay helper in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php` and wire it to reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` without introducing a second control taxonomy, new persistence table, or framework registry
- [x] T006 Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` to compose one shared summary/detail interpretation payload into the existing `TenantReview` and `TenantReviewSection` JSON only
- [x] T007 Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Models/TenantReview.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Models/TenantReviewSection.php` with narrow helpers for the stored interpretation version, summary list, limitation counts, and detail-section access so workspace and detail surfaces read one meaning path
- [x] T008 [P] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` to freeze deny-as-not-found scope handling, read-only customer-workspace posture, unchanged global-search disablement, and the absence of new destructive or authoring actions on the touched surfaces
**Checkpoint**: The stored interpretation contract, access helpers, and no-scope-creep guardrails are fixed before workspace or detail rendering work begins.
---
## Phase 3: User Story 1 - Understand Control Readiness At A Glance (Priority: P1) 🎯 MVP
**Goal**: Let an entitled reviewer open the existing customer review workspace and immediately understand which control areas need follow-up, what evidence basis exists, and what next action is recommended.
**Independent Test**: Open `/admin/reviews/workspace` as an entitled read-only actor and confirm each visible tenant shows only the latest released review, a customer-safe mapped-control summary, explicit limitation states, interpretation version disclosure, and one dominant `Open released review` path.
### Tests for User Story 1
- [x] T009 [P] [US1] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` for visible interpretation version, non-certification disclosure, control summaries, limitation states, evidence-basis wording, recommended next action, explicit partial or unmapped rows, and the truthful page-level empty state when no entitled released review exists
- [x] T010 [P] [US1] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` for safe tenant-prefilter launch behavior and one dominant `Open released review` path from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` that keeps the core customer-safe flow within two interactions or fewer
### Implementation for User Story 1
- [x] T011 [US1] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` to build one workspace entry per entitled tenant with a latest released `TenantReview` from its stored `control_interpretation` summary only, while keeping the no-released-review case as a page-level empty state
- [x] T012 [US1] Render the mapped-control workspace summary in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` with interpretation version, non-certification disclosure, evidence basis, limitation flags, and no competing primary action
- [x] T013 [US1] Keep row-open, tenant-prefilter, and return-path behavior aligned in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` so the dominant inspect path stays the released-review detail without widening discovery
- [x] T014 [US1] Add workspace summary and limitation wording to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` using localization-ready customer-safe labels instead of certification or framework-specific language
**Checkpoint**: User Story 1 is independently functional when the workspace truthfully shows released review summaries for entitled tenants with one dominant inspect path and explicit limitation handling.
---
## Phase 4: User Story 2 - Understand Why A Control Reads This Way (Priority: P1)
**Goal**: Let the same actor open the released review detail from the workspace and understand the per-control explanation, evidence basis, accepted-risk influence, and recommended next step without seeing operator-only residue.
**Independent Test**: Open a released review from the workspace and verify that each surfaced control explains its state through stored interpretation payloads, stays read-only in `customer_workspace` mode, and keeps supporting evidence as explicit secondary drilldown only.
### Tests for User Story 2
- [x] T015 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php` for per-control explanation, accepted-risk influence, evidence-basis items, limitation disclosure, and consistency with the stored workspace summary
- [x] T016 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` for `customer_workspace=1` launch semantics, read-only detail mode, and explanation-first layout with no competing header actions
### Implementation for User Story 2
- [x] T017 [US2] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` to read the shared interpretation section from `TenantReviewSection` and keep customer-workspace mode strictly read-only
- [x] T018 [US2] Reuse the stored interpretation payload in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` to render per-control explanation text, evidence basis, accepted-risk context, limitation flags, and recommended next action without page-local remapping
- [x] T019 [US2] Wire supporting-evidence drilldown through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` so proof stays an explicit in-body, capability-gated route reuse
- [x] T020 [US2] Add released-review explanation and supporting-evidence wording to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` so workspace and detail surfaces share one customer-safe vocabulary
**Checkpoint**: User Story 2 is independently functional when the released review detail deepens the same mapped-control meaning without exposing operator actions, duplicate decision summaries, or raw support detail by default.
---
## Phase 5: User Story 3 - Trust The Interpretation Basis And Its Limits (Priority: P2)
**Goal**: Let an entitled reviewer understand which interpretation version they are reading, how that version is traced through audit metadata, and how secondary evidence routes behave without leaking cross-tenant truth.
**Independent Test**: Open the workspace, released review detail, and an entitled supporting-evidence route; verify interpretation-version continuity, non-certification wording, audit metadata traceability, capability-gated secondary-path behavior, and deny-as-not-found handling for out-of-scope tenant targets.
### Tests for User Story 3
- [x] T021 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` for `interpretation_version`, `source_surface`, `review_id`, and `tenant_filter_id` metadata on released-review and evidence-open events
- [x] T022 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` for capability-gated evidence reuse, visible interpretation-version continuity, non-certification wording, and workspace-to-detail drilldown behavior
### Implementation for User Story 3
- [x] T023 [US3] Enrich `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Audit/AuditActionId.php` metadata handling for `customer_review_workspace.opened`, `tenant_review.opened`, and `evidence_snapshot.opened` without introducing new audit events or stores
- Evidence: existing audit events and logger were reused; metadata enrichment is implemented at the existing workspace, review, evidence, and review-pack download call sites, so no new `AuditActionId` value or logger contract change was needed.
- [x] T024 [US3] Propagate `source_surface`, `tenant_filter_id`, `review_id`, and `interpretation_version` through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` so workspace, detail, and proof reuse one traceable interpretation path
- [x] T025 [US3] Tighten `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` so out-of-scope tenant requests stay `404` while in-scope actors get explicit secondary-path denial or unavailability only when capability-gated
- [x] T026 [US3] Add version-traceability and non-certification localization keys to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, keeping Governance-as-a-Service packaging and framework-specific overlays explicitly out of visible copy
**Checkpoint**: User Story 3 is independently functional when interpretation version and audit traceability stay consistent across workspace, detail, and proof surfaces without widening discovery or implying certification.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Run the narrow validation set, keep formatting clean, and record bounded reviewer outcomes without widening scope.
- [x] T027 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
- [x] T028 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
- [x] T029 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- [x] T030 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [x] T031 Record the final `Guardrail / Smoke Coverage` close-out, shared-interpretation-path outcome, audit-metadata reuse outcome, global-search safety outcome, list-surface review outcome, and any `document-in-feature` or `follow-up-spec` decisions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/259-compliance-evidence-mapping/checklists/requirements.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 until the one shared interpretation contract and base guardrails are fixed.
- **Phase 3 (US1)**: depends on Phase 2 and delivers the MVP workspace interpretation slice.
- **Phase 4 (US2)**: depends on Phase 2 and should follow US1 because the released-review detail must explain the same stored workspace summary on the same shared path.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because version traceability and evidence-route reuse depend on the shared interpretation already being visible on both surfaces.
- **Phase 6 (Polish)**: depends on all implemented stories.
### User Story Dependencies
- **US1 (P1)**: first independently shippable increment once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because it deepens the same stored interpretation contract on the released-review detail surface.
- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 and US2 because audit metadata and evidence-route behavior depend on the shared interpretation being visible end-to-end.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap before runtime implementation.
- Reuse the stored interpretation contract, existing capability checks, and current audit logger before introducing any local mapper, route family, or copy-only duplication.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Parallel Execution Examples
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004 and T008 can run in parallel while T005 through T007 settle the shared interpretation contract and model-access path.
### User Story 1
- T009 and T010 can run in parallel before runtime edits begin.
- After T011 settles row composition, T012 and T014 can proceed before T013 finalizes launch and inspect behavior.
### User Story 2
- T015 and T016 can run in parallel before detail-surface edits begin.
- After T017 lands the read-only detail mode, T018 and T020 can proceed before T019 finalizes secondary evidence drilldown.
### User Story 3
- T021 and T022 can run in parallel before audit and proof-path implementation begins.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + User Story 1** only. That delivers the shared interpretation contract plus the workspace rendering that makes the customer-safe control/readiness overlay visible without yet deepening detail and proof behavior.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the workspace interpretation contract.
3. Deliver US2 and validate the released-review explanation path.
4. Deliver US3 and validate audit traceability plus evidence-route reuse.
5. Finish with Phase 6 validation, formatting, and reviewer close-out notes.
### Team Strategy
1. Settle `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` first because every surface depends on that stored interpretation payload.
2. Parallelize test authoring inside each story before converging on the shared workspace, detail, and evidence files.
3. Serialize merges around `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/{en,de}/localization.php` because they are the highest-conflict hotspots for this slice.
---
## Notes
- This file plans implementation only. No application code is changed by the task-generation step.
- The interpretation layer stays bounded to one versioned overlay over existing canonical control references and released review truth.
- No new panel/provider, no OperationRun UX, no destructive actions, no new persistence table, no new report engine, no new asset strategy, no global-search expansion, no framework-specific overlay work, and no Governance-as-a-Service packaging work are included in these tasks.

View File

@ -1,51 +0,0 @@
# Specification Quality Checklist: Governance-as-a-Service Packaging v1
**Purpose**: Validate specification completeness and repo fit before planning
**Created**: 2026-05-01
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details leak into the feature contract beyond repo-real route and surface references needed for local convention fit
- [x] The spec is focused on user value, trust, sellability, and behavior rather than an implementation diff
- [x] The spec is written so non-technical stakeholders can understand the product outcome and scope boundaries
- [x] All mandatory sections requested by the repo template and constitution are completed
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic enough to validate product outcome rather than code shape
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions are identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover the primary packaging flows
- [x] The feature stays on one bounded on-demand package for one released review context
- [x] The spec explicitly depends on the shared customer-safe interpretation layer and forbids bypassing it
- [x] The roadmap phrase `open decisions` is narrowed to repo-real exception or risk-acceptance governance decision truth
- [x] No new panel, package domain, report engine, schedule, or campaign system is introduced
- [x] Existing review, review-pack, evidence, and stored-report surfaces remain the reuse path
## Test Governance
- [x] Planned validation stays bounded to focused `confidence` coverage plus one explicit `browser` smoke
- [x] The spec reuses existing review, tenant-review, review-pack, and evidence test families instead of introducing a new heavy family
- [x] Reviewer handoff and minimal proof commands are explicit
## Notes
- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `specs/257-governance-decision-convergence/spec.md`, `specs/258-customer-review-productization/spec.md`, `specs/259-compliance-evidence-mapping/spec.md`, and `.specify/memory/constitution.md` on 2026-05-01.
- No application implementation was performed while preparing this spec.
## Review Outcome
- **Outcome class**: `repo-fit-ready`
- **Outcome**: `keep`
- **Reason**: The spec stays narrowly on one management-ready package over existing released-review truth, explicitly records the interpretation dependency, narrows the ambiguous `open decisions` phrase to repo-real governance decision truth, and avoids introducing a second package domain.
- **Workflow result**: Ready for `/speckit.plan`

View File

@ -1,427 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Governance-as-a-Service Packaging v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the Governance-as-a-Service Packaging v1 planning package.
These paths describe existing Filament admin and tenant-scoped routes reused by
the implementation. The schemas document the derived package summary and
package-availability contract expected over existing review, review-pack,
evidence, stored-report-backed evidence, and governance-decision truth; they
do not define a new public REST API.
servers:
- url: /
paths:
/admin/reviews/workspace:
get:
summary: View management-ready package readiness in the customer review workspace
description: |
Existing admin-plane workspace page reused as the primary decision surface.
The route remains read-only, tenant-safe, and package-readiness remains
informational rather than a second competing action path.
parameters:
- in: query
name: tenant
required: false
schema:
type: string
description: |
Optional tenant prefilter using the current tenant id or external id
pattern already accepted by the workspace page.
responses:
'200':
description: Workspace page rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/CustomerReviewWorkspacePageModel'
'404':
description: Not found for non-members, actors without entitled tenants, or explicit out-of-scope tenant targeting
/admin/t/{tenant}/reviews/{review}:
get:
summary: Open the management-ready package context for a released review
description: |
Existing tenant-scoped released-review detail route reused as the secondary
package context surface. The customer-workspace flow uses the existing
`customer_workspace=1` query flag to keep the detail read-only and
management-ready.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: review
required: true
schema:
type: integer
- in: query
name: customer_workspace
required: false
schema:
type: boolean
description: Existing query-context flag that suppresses operator lifecycle actions on the detail surface.
responses:
'200':
description: Released-review detail rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/GovernancePackageDetailModel'
'403':
description: Forbidden for an in-scope actor missing the inherited review-view capability on the reused route
'404':
description: Not found for non-members, tenant mismatches, or out-of-scope review targets
/admin/review-packs/{reviewPack}/download:
get:
summary: Download the current review-pack-backed governance package artifact
description: |
Existing signed review-pack download route reused as the default package
delivery seam. The management-ready path should reuse this route rather than
creating a new package artifact namespace or triggering generation.
parameters:
- in: path
name: reviewPack
required: true
schema:
type: integer
- in: query
name: source_surface
required: false
schema:
type: string
- in: query
name: review_id
required: false
schema:
type: integer
- in: query
name: tenant_filter_id
required: false
schema:
type: integer
- in: query
name: interpretation_version
required: false
schema:
type: string
responses:
'302':
description: Signed download redirect to the current review-pack artifact
'403':
description: Forbidden for an in-scope actor missing the review-pack capability or a valid signature
'404':
description: Not found for non-members, tenant mismatches, or inaccessible review packs
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
get:
summary: Open supporting evidence from the management-ready package context
description: |
Existing tenant-scoped evidence detail route reused only after explicit
drilldown from the released-review detail surface and current capability
checks.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: evidenceSnapshot
required: true
schema:
type: integer
- in: query
name: source_surface
required: false
schema:
type: string
description: Existing source-surface metadata hook reused by the shared audit path.
responses:
'200':
description: Evidence proof detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an in-scope actor missing the evidence capability
'404':
description: Not found for non-members, mismatched tenant scope, or unavailable evidence targets
components:
schemas:
ControlInterpretationVersion:
type: object
required:
- version_key
- display_label
properties:
version_key:
type: string
example: compliance_evidence_mapping.v1
display_label:
type: string
calm_disclosure:
type: string
nullable: true
PackageAvailabilityState:
type: object
description: Package-facing availability derived from the existing artifact-truth seam. Canonical mapping is publishable -> available, internal_only or stale -> partial, blocked or missing_input -> unavailable, historical_only -> expired, and package-level entitlement restriction -> blocked. Secondary-artifact restrictions do not force the top-level package state to blocked when the summary remains readable.
required:
- state
- message
properties:
state:
type: string
enum:
- available
- partial
- unavailable
- expired
- blocked
message:
type: string
reason_code:
type: string
nullable: true
description: Explains why the package is not fully available without creating a second availability taxonomy.
enum:
- current_review_pack_missing
- current_review_pack_expired
- stale_basis
- evidence_basis_partial
- interpretation_missing
- source_not_publishable
- entitlement_restricted
download_url:
type: string
nullable: true
SupportingArtifactLink:
type: object
description: Optional proof or artifact drilldown. A supporting link may be forbidden or unavailable while the top-level package summary remains readable.
required:
- artifact_family
- state
- label
properties:
artifact_family:
type: string
enum:
- review_pack
- evidence_snapshot
- stored_report
state:
type: string
enum:
- available
- partial
- unavailable
- expired
- redacted
- forbidden
label:
type: string
message:
type: string
nullable: true
url:
type: string
nullable: true
ManagementFindingHighlight:
type: object
required:
- label
- customer_summary
properties:
finding_id:
type: integer
nullable: true
label:
type: string
severity:
type: string
nullable: true
customer_summary:
type: string
control_key:
type: string
nullable: true
GovernanceDecisionFollowUp:
type: object
required:
- decision_source
- decision_state
- decision_summary
properties:
decision_source:
type: string
enum:
- finding_exception
- risk_acceptance
decision_state:
type: string
enum:
- awareness_required
- expiring
- expired
- revoked
- missing_basis
decision_summary:
type: string
accountable_label:
type: string
nullable: true
review_due_at:
type: string
format: date-time
nullable: true
expires_at:
type: string
format: date-time
nullable: true
GovernancePackageSummary:
type: object
required:
- review_id
- tenant_id
- interpretation
- delivery_artifact_family
- package_availability
- executive_summary
properties:
review_id:
type: integer
tenant_id:
type: integer
interpretation:
$ref: '#/components/schemas/ControlInterpretationVersion'
delivery_artifact_family:
type: string
enum:
- review_pack
package_availability:
$ref: '#/components/schemas/PackageAvailabilityState'
executive_summary:
type: string
highlights:
type: array
items:
type: string
top_findings:
type: array
items:
$ref: '#/components/schemas/ManagementFindingHighlight'
accepted_risks:
type: array
description: Currently valid tolerated-risk positions that explain why material risk is presently accepted and does not require immediate stakeholder follow-up beyond awareness.
items:
$ref: '#/components/schemas/GovernanceDecisionFollowUp'
governance_decisions:
type: array
description: Accepted-risk or exception decision entries that still require stakeholder awareness or follow-up. Entries must be disjoint from accepted_risks for the same review.
items:
$ref: '#/components/schemas/GovernanceDecisionFollowUp'
evidence_basis_summary:
type: string
nullable: true
recommended_next_action:
type: string
nullable: true
supporting_artifacts:
type: array
items:
$ref: '#/components/schemas/SupportingArtifactLink'
CustomerReviewWorkspaceEntry:
type: object
required:
- tenant_id
- tenant_name
- latest_published_review_id
- package_availability
- control_readiness
properties:
tenant_id:
type: integer
tenant_name:
type: string
latest_published_review_id:
type: integer
latest_review_published_at:
type: string
format: date-time
nullable: true
interpretation:
$ref: '#/components/schemas/ControlInterpretationVersion'
package_availability:
$ref: '#/components/schemas/PackageAvailabilityState'
control_readiness:
type: string
evidence_basis:
type: string
nullable: true
recommended_next_action:
type: string
nullable: true
evidence_proof_state:
type: string
nullable: true
management_teaser:
type: string
nullable: true
CustomerReviewWorkspacePageModel:
type: object
required:
- workspace_id
- entries
properties:
workspace_id:
type: integer
tenant_filter_id:
type: integer
nullable: true
entries:
type: array
items:
$ref: '#/components/schemas/CustomerReviewWorkspaceEntry'
GovernancePackageDetailModel:
type: object
required:
- review
- package
properties:
review:
type: object
required:
- review_id
- tenant_id
- published_at
properties:
review_id:
type: integer
tenant_id:
type: integer
published_at:
type: string
format: date-time
nullable: true
package:
$ref: '#/components/schemas/GovernancePackageSummary'

View File

@ -1,317 +0,0 @@
# Data Model — Governance-as-a-Service Packaging v1
**Spec**: [spec.md](spec.md)
No new persisted table, artifact family, or package projection store is required for this feature. The package remains a derived management-ready view over current review, section, review-pack, evidence, stored-report, governance-decision, entitlement, and audit truth.
## Source Truth Reused
### Workspace / Tenant Entitlement Context
**Purpose**: Establish the active workspace boundary and entitled tenant set before workspace rows, released-review detail, package download, or supporting artifact links resolve.
**Persisted carriers**:
- existing workspace membership rows
- existing tenant membership pivot rows and role assignments
- existing capability registry and role-capability map
**Relevant fields / contracts**:
- `workspace_id`
- `tenant_id`
- workspace membership existence
- tenant membership role
- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php)
- remembered tenant context from the current workspace session model
**Validation rules**:
- actors outside the current workspace or tenant scope resolve as not found
- package readiness is only aggregated for entitled tenants with eligible released reviews
- supporting artifact availability must not leak inaccessible tenant or review presence
### ReleasedReviewContext
**Purpose**: Existing tenant review that anchors all package content and package-access semantics.
**Persisted carrier**: existing `tenant_reviews` rows via [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php)
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `generated_at`
- `published_at`
- `summary`
- `evidence_snapshot_id`
- `current_export_review_pack_id`
- `tenant`
- `evidenceSnapshot`
- `sections`
**Validation / usage rules**:
- only released reviews feed the management-ready package path
- the review remains the canonical package-owning context; package output must not become independent truth about review state
- existing `summary.control_interpretation`, `summary.highlights`, and related summary data remain the first reuse path for package meaning
### TenantReviewSection Family
**Purpose**: Existing released-review section set that already captures the management-relevant content needed for packaging.
**Persisted carrier**: existing `tenant_review_sections` rows via [../../apps/platform/app/Models/TenantReviewSection.php](../../apps/platform/app/Models/TenantReviewSection.php)
**Current section keys already relevant to packaging**:
- `executive_summary`
- `control_interpretation`
- `open_risks`
- `accepted_risks`
- `permission_posture`
- `baseline_drift_posture`
- `operations_health`
**Relevant fields**:
- `section_key`
- `title`
- `sort_order`
- `completeness_state`
- `summary_payload`
- `render_payload`
- `measured_at`
**Validation / usage rules**:
- package sections should be rendered from this current section family before any new payload is considered
- accepted-risk and governance-decision follow-up stays anchored to the current `accepted_risks` section and related persisted decision truth
- if implementation later needs a small additional helper field, it must stay embedded inside existing review payloads rather than creating a new package entity
### Shared Interpretation Summary
**Purpose**: Existing management-meaning layer from Spec 259 that translates technical review truth into calm customer-safe language.
**Primary producer**: [../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php](../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php) via [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php) and [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php)
**Current carriers**:
- `TenantReview.summary['control_interpretation']`
- `TenantReviewSection` with `section_key = control_interpretation`
**Relevant fields / payload**:
- interpretation version
- control readiness summaries
- evidence-basis summaries
- limitation and follow-up wording
- per-control entries rendered for deeper detail
**Validation / usage rules**:
- package meaning must remain dependent on this shared interpretation layer
- if the interpretation layer or version is unavailable, the package must show explicit partial or unavailable state and must not infer management meaning directly from raw findings or stored reports
### ReviewPack
**Purpose**: Existing packaged export artifact reused as the default package-delivery seam for stakeholder download.
**Persisted carrier**: existing `review_packs` rows via [../../apps/platform/app/Models/ReviewPack.php](../../apps/platform/app/Models/ReviewPack.php)
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `tenant_review_id`
- `status`
- `expires_at`
- `summary`
- `options`
- `tenantReview`
- `evidenceSnapshot`
**Validation / usage rules**:
- the package path should prefer reusing `currentExportReviewPack` and its signed download route
- management-ready package access must not create a new review-pack generation run or a new package artifact family
- review-pack readiness, expiry, missing-input, and blocked states remain review-pack truth, not new package lifecycle state
### EvidenceSnapshot
**Purpose**: Existing supporting proof artifact that informs evidence-basis summaries and explicit deeper drilldown.
**Persisted carrier**: existing `evidence_snapshots` rows via [../../apps/platform/app/Models/EvidenceSnapshot.php](../../apps/platform/app/Models/EvidenceSnapshot.php)
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `completeness_state`
- `generated_at`
- `expires_at`
- `summary`
- `items`
**Validation / usage rules**:
- evidence remains supporting truth and must stay distinct from package summary truth
- default-visible package content may summarize the evidence basis, but raw evidence payloads remain secondary and capability-gated
- supporting proof access should reuse existing evidence routes and audit events
### StoredReport-backed Evidence Inputs
**Purpose**: Existing report artifacts that feed evidence dimensions and therefore indirectly support the package evidence basis.
**Persisted carriers**:
- existing `stored_reports` rows via [../../apps/platform/app/Models/StoredReport.php](../../apps/platform/app/Models/StoredReport.php)
- existing evidence-source seams such as [../../apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php](../../apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php) and [../../apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php](../../apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php)
**Relevant fields / contracts**:
- `StoredReport.id`
- `workspace_id`
- `tenant_id`
- `report_type`
- `fingerprint`
- current evidence-source `source_kind = stored_report`
- current evidence-source `source_record_id`
**Validation / usage rules**:
- stored reports remain subordinate source artifacts behind evidence and review truth
- the package may mention stored-report-backed evidence basis, but it must not default to raw report payloads or invent a new viewer shell in v1
- if no current entitled viewer seam exists, the package should prefer explicit unavailable or secondary context messaging over a new route
### GovernanceDecisionTruth
**Purpose**: Existing accepted-risk and exception decision truth that qualifies management-ready package claims and powers the narrowed `open decisions` meaning.
**Persisted carriers**:
- existing `finding_exceptions` rows via [../../apps/platform/app/Models/FindingException.php](../../apps/platform/app/Models/FindingException.php)
- existing `finding_exception_decisions` truth referenced by the current exception relationships
**Relevant fields / relationships**:
- exception `status`
- `current_validity_state`
- `owner_user_id`
- `approved_by_user_id`
- `request_reason`
- `review_due_at`
- `effective_from`
- `expires_at`
- `owner`
- `approver`
- `currentDecision`
**Validation / usage rules**:
- `open decisions` remains narrowed to this existing accepted-risk / exception decision truth only
- package wording may summarize accountable role or person, decision reason, follow-up, and validity timing where current product truth exists
- missing owner, approval, or timing truth must surface as explicit partial disclosure instead of invented certainty
### Audit Log Event Family
**Purpose**: Existing audit trail used to keep package access and supporting artifact consumption attributable.
**Persisted carrier**: existing `audit_logs` rows via [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php)
**Relevant current action IDs**:
- `customer_review_workspace.opened`
- `tenant_review.opened`
- `review_pack.downloaded`
- `evidence_snapshot.opened`
**Validation / usage rules**:
- no new audit store or audit family is justified by default
- package access and supporting artifact access should reuse these events with shared metadata before introducing a new event concept
## Derived Contracts
### GovernancePackageSummaryProjection
**Purpose**: Conceptual derived package contract rendered from the released review and supporting artifacts.
**Persistence**: not persisted independently; derived from `TenantReview.summary`, existing section payloads, current review-pack truth, evidence truth, and governance-decision truth
**Fields**:
- `review_id`
- `tenant_id`
- `interpretation_version`
- `delivery_artifact_family = review_pack`
- `package_availability`
- `executive_summary`
- `top_findings[]`
- `accepted_risks[]`
- `governance_decisions[]`
- `evidence_basis_summary`
- `supporting_artifact_links[]`
- `calm_disclosure`
**Field boundary rules**:
- `accepted_risks[]` contains currently valid tolerated-risk positions that explain why a material issue is presently accepted and does not need immediate stakeholder follow-up beyond awareness.
- `governance_decisions[]` contains accepted-risk or exception decision entries that still require stakeholder awareness because they are expiring, expired, revoked, missing basis, or otherwise need follow-up.
- the same underlying accepted-risk or exception decision must not appear in both arrays for the same released review; if an entry qualifies for both, prefer `governance_decisions[]`.
**Validation rules**:
- the projection must not become independent product truth about review, evidence, pack, or governance state
- the downloadable artifact for v1 remains the current export review pack for the released review; the governance package summary is derived framing over that existing artifact, not a second export family
- package meaning must stay consistent between workspace summary, released-review detail, and package delivery for the same released review
- if implementation later needs a tiny helper shape, it must stay embedded inside current review/resource helpers rather than new persistence
### PackageAvailabilityState
**Purpose**: Derived explanation of whether the current released review can deliver the management-ready package now.
**Persistence**: derived from current review-pack truth, evidence basis completeness, interpretation availability, and entitlement state
**Planned values**:
- `available`
- `partial`
- `unavailable`
- `expired`
- `blocked`
**Validation rules**:
- these are presentation semantics only and must not become a new persisted lifecycle family
- stale or incomplete basis resolves through `partial` or `unavailable` reasons such as `stale_basis` or `evidence_basis_partial`, not a separate top-level state
- entitlement-restricted package access resolves through `blocked` or per-artifact `forbidden` semantics, not a separate top-level availability state
- reasons should remain attributable to the underlying review-pack, evidence, entitlement, or interpretation truth
**Canonical mapping from current artifact-truth seam**:
| Current repo truth | Package state | Reason-code guidance | Notes |
|---|---|---|---|
| `publicationReadiness = publishable` and current pack truth | `available` | `null` unless a secondary artifact has its own issue | The current export review pack is ready for stakeholder delivery. |
| `publicationReadiness = internal_only` or freshness `stale` while the summary is still readable | `partial` | `stale_basis` or `evidence_basis_partial` | Keep the management summary readable, but be explicit that the delivery basis is incomplete or stale. |
| `publicationReadiness = blocked`, `artifactExistence = not_created`, `contentState = missing_input`, or interpretation/source truth is not publishable | `unavailable` | `current_review_pack_missing`, `interpretation_missing`, or `source_not_publishable` | The package cannot be truthfully delivered now, so do not imply download readiness. |
| `artifactExistence = historical_only` or current pack expiry is the governing truth | `expired` | `current_review_pack_expired` | Historical exports stay attributable, but they are not the current stakeholder package. |
| In-scope actor lacks package entitlement to the released-review package path | `blocked` | `entitlement_restricted` | The package path itself is gated and must not render as deliverable. |
| Package summary is readable, but one supporting proof or artifact link is restricted | package state stays `available` or `partial` according to review-pack truth; affected secondary link becomes `forbidden` or `unavailable` | `supporting_access_limited` belongs on the supporting link message, not on the top-level package state | Keep package-level access and per-artifact access distinct. |
**Additional mapping notes**:
- repo directions such as `usable` stay inside `SupportingArtifactLink.state` and do not create a sixth package state
- repo directions such as `follow_up_needed` affect governance-follow-up wording and calm disclosure, not top-level package availability on their own
### GovernanceDecisionFollowUp
**Purpose**: Derived management-readable summary of accepted-risk / exception decisions that still need stakeholder awareness.
**Persistence**: derived from current exception and decision truth only
**Fields**:
- `decision_source`
- `decision_state`
- `decision_summary`
- `accountable_label` (nullable)
- `review_due_at` (nullable)
- `expires_at` (nullable)
- `follow_up_message`
**Validation rules**:
- follow-up entries must not imply a broader queue or workflow board
- only currently valid accepted-risk / exception decision truth may feed this projection
### SupportingArtifactLink
**Purpose**: Derived representation of optional proof and artifact drilldowns from the package.
**Persistence**: derived from current evidence, review-pack, and existing viewer availability
**Fields**:
- `artifact_family`
- `state`
- `label`
- `message`
- `url` (nullable)
**Validation rules**:
- links must point only to existing entitled surfaces
- if no current viewer seam exists for a stored-report-backed detail, the link stays unavailable rather than introducing a new route
- the package must not duplicate raw payloads when a secondary artifact link is unavailable

View File

@ -1,322 +0,0 @@
# Implementation Plan: Governance-as-a-Service Packaging v1
**Branch**: `260-governance-service-packaging` | **Date**: 2026-05-01 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
## Summary
Prepare one bounded, on-demand management-ready governance package inside the existing customer review workspace and released-review detail flow. The narrow implementation path is to reuse current released-review summary and section truth, the shared `control_interpretation` layer from Spec 259, the existing current-review-pack signed-download seam, and the shared artifact-truth presenter for availability or expiry semantics, while keeping package access read-only, tenant-safe, auditable, localization-ready, and derived.
Repo truth already supports the key seams this feature needs: [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) already exposes `control_readiness`, `evidence_basis`, `recommended_next_action`, and `evidence_proof_state` and audits workspace opens; [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) already has a customer-workspace-specific read-only mode with `downloadCurrentReviewPackAction()` while keeping `exportExecutivePackAction()` operator-only; [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already provides review-derived pack generation and signed download URLs; and [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) already understands `TenantReview`, `EvidenceSnapshot`, and `ReviewPack` truth.
V1 therefore stays narrow: no new panel or provider, no new package artifact family, no new report engine, no schedule or batch flow, no campaign system, no AI summary layer, no PSA or CRM workflow, no package-generation run from the management-ready path, and no cross-tenant packaging.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure
**Storage**: PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `review_packs`, `evidence_snapshots`, `evidence_snapshot_items`, `stored_reports`, `findings`, `finding_exceptions`, `finding_exception_decisions`, memberships, and `audit_logs`; no new persistence planned
**Testing**: Pest v4 feature and focused unit coverage plus one bounded browser smoke on the existing review flow
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel monolith in `apps/platform`, existing admin plane only (`/admin` plus existing tenant-scoped `/admin/t/{tenant}` reuse)
**Project Type**: Web application (Laravel monolith with Filament pages/resources)
**Performance Goals**: keep workspace and released-review rendering DB-only and scope-safe, reuse already composed review summary and section payloads plus current review-pack or evidence truth, and avoid new Graph calls, queue starts, or heavy asset work on the read path
**Constraints**: no new panel/provider, no new `GovernancePackage` persistence family, no new report engine, no scheduling/batching/campaign system, no AI summaries, no PSA/CRM workflow, no package-generation runs from the management-ready path, no cross-tenant packaging, no global-search expansion, and no asset strategy change
**Scale/Scope**: 1 existing workspace page, 1 existing released-review detail page, 1 existing signed review-pack download route, existing evidence and review-pack truth seams, existing stored-report-backed evidence sources, shared audit infrastructure, and focused reuse of the current `Reviews`, `TenantReview`, `ReviewPack`, `Evidence`, and browser test families
## Likely Affected Repo Surfaces
- [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) for package-readiness wording, calm disclosure, and consistency with the current `control_readiness`, `evidence_basis`, `recommended_next_action`, and `evidence_proof_state` columns.
- [../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php](../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php) for package framing and management-ready disclosure at the page-intro level.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php) and [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) for the released-review detail contract, customer-workspace read-only mode, one dominant package action, and operator-only export separation.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php) and [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php) for the already composed `summary.control_interpretation`, `summary.highlights`, and current section family (`executive_summary`, `control_interpretation`, `open_risks`, `accepted_risks`, `permission_posture`, `baseline_drift_posture`, `operations_health`).
- [../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php](../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php) for the shared interpretation dependency and existing accepted-risk follow-up language.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) and [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) for current review-pack truth, signed downloads, and current audit boundaries.
- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) and related envelope/compression helpers for `TenantReview`, `EvidenceSnapshot`, and `ReviewPack` availability, freshness, and artifact/result truth separation.
- [../../apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php](../../apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php) and [../../apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php](../../apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php) as the current stored-report-backed evidence seams; planning should avoid inventing a new stored-report viewer when these sources already feed the review and evidence basis.
- [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) and existing evidence detail pages for secondary proof routes and explicit unavailable-state behavior.
- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for reusable audit events and metadata.
- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php) and [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) for capability-first RBAC and deny-as-not-found boundaries.
- [../../apps/platform/lang/en/localization.php](../../apps/platform/lang/en/localization.php) and [../../apps/platform/lang/de/localization.php](../../apps/platform/lang/de/localization.php) for management-ready and customer-safe wording.
- [../../apps/platform/tests/Unit/TenantReview/TenantReviewComposerTest.php](../../apps/platform/tests/Unit/TenantReview/TenantReviewComposerTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php), [../../apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php), [../../apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php), [../../apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php), [../../apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php](../../apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php), [../../apps/platform/tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php](../../apps/platform/tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php), [../../apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php](../../apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php), [../../apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php](../../apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php), [../../apps/platform/tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php](../../apps/platform/tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php), [../../apps/platform/tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php](../../apps/platform/tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php), [../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php](../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php), [../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php](../../apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php), and [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) for the bounded proving lane already present in the repo.
## Packaging / Artifact Reuse Fit
- Keep the released review as the only package-owning context. The package summary should be derived from the already composed review summary and section payloads rather than from a new `GovernancePackage` record or a new report namespace.
- Repo truth already prefers current review-pack reuse over package generation on the customer-safe path. In customer-workspace mode [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) exposes `downloadCurrentReviewPackAction()` only and routes it through the current signed-download URL, while `exportExecutivePackAction()` remains outside the customer-workspace flow.
- The management-ready path should therefore begin with current `currentExportReviewPack` availability, signed download reuse, and explicit unavailable or expired states. It must not call [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php)`::generateFromReview()` from the package path and must not start a new `OperationRun` or report-generation sequence.
- In v1, `Download governance package` is governance-package framing for downloading the current export review pack for the released review. The summary shown on the page is derived presentation over that artifact and related evidence truth, not a second downloadable export family.
- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) already has `forReviewPack()`, `forEvidenceSnapshot()`, and tenant-review seams. Package readiness, expiration, blocked, historical-only, or missing-input states should piggyback on those existing artifact-truth semantics instead of creating a new package state family.
- Stored reports remain subordinate source truth. The repo currently exposes them mainly through evidence-source and widget seams, not through an obvious shared stored-report viewer resource. V1 should therefore summarize stored-report-backed evidence basis where needed and use explicit unavailable or secondary existing-viewer behavior instead of inventing a new stored-report viewer route.
- The roadmap phrase `open decisions` must stay narrowed to current accepted-risk and exception decision truth from `FindingException` and `FindingExceptionDecision`. The package does not become a broader governance inbox or approval queue.
## UI / Filament & Livewire Fit
- Keep [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) as the primary decision surface. This plan tightens the existing page instead of adding a new page class, Resource, panel, provider, or cluster.
- Keep [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) as the only secondary context surface, using the existing customer-workspace read-only mode rather than creating a new package detail shell.
- Filament remains v5 on Livewire v4. Provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), and this feature does not plan any panel or provider changes.
- Global search posture stays unchanged. [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) are already globally disabled, and this plan adds no new globally searchable resource.
- Keep one dominant next action per surface. On the workspace page that remains `Open released review`; package readiness stays informational and should not become a competing row action. On released-review detail the dominant action remains a safe package download path rooted in the current review-pack seam.
- No destructive actions are introduced for the management-ready package path. Existing destructive or mutating review lifecycle actions on the normal operator detail page remain outside this slice and continue to rely on existing authorization and confirmation behavior.
- Asset strategy remains unchanged. No new Filament asset registration is planned. If a later implementation unexpectedly registers assets, deployment still uses the existing `cd apps/platform && php artisan filament:assets` step, but no such change is expected here.
## RBAC / Policy Fit
- Workspace membership remains the first isolation boundary via the current workspace context and [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php). Non-members and actors without any entitled tenant in the current workspace remain `404`.
- Tenant scope remains the second isolation boundary. Released-review detail, review-pack download, evidence proof, and any secondary drilldown must stay tied to the selected tenant and deny cross-tenant access as not found.
- Capability reuse remains mandatory. Package access should ride current review visibility plus current `REVIEW_PACK_VIEW` and `EVIDENCE_VIEW` capability checks rather than introducing raw strings or role-name branches.
- Inside an established scope, the reused released-review detail route or secondary artifact access may still fail as capability denial when inherited capability checks deny access. The page-level contract should prefer calm unavailable messaging where the summary remains readable, while inherited detail-route and direct execution endpoints keep current `403` or signed-route enforcement semantics.
- Cross-tenant packaging is explicitly out of scope. The workspace may only show package readiness for entitled tenants and their eligible released reviews.
## Audit / Logging Fit
- Reuse [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) as the only audit path.
- [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) already logs `CustomerReviewWorkspaceOpened` with `source_surface`, tenant-filter metadata, and interpretation-version context.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) already logs `TenantReviewOpened` with the customer-workspace source and interpretation version when entered from the workspace.
- Current review-pack downloads already flow through `ReviewPackDownloaded` on the signed route. Planning should prefer reusing that event plus metadata over creating a new package-download audit family.
- Secondary proof access should remain on existing evidence-open audit paths. If a distinct management-ready package open event is ever proposed beyond current review open and pack download, that should first be justified as additive metadata on the existing audit path before a new event family is considered.
## Data & Query Fit
- Package summary should start from existing released-review truth already composed in [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php): `summary.control_interpretation`, `summary.highlights`, `summary.recommended_next_actions`, and the current evidence-basis block.
- Detailed package sections should reuse the current section family from [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php): `executive_summary`, `control_interpretation`, `open_risks`, `accepted_risks`, `permission_posture`, `baseline_drift_posture`, and `operations_health`.
- Governance-decision follow-up must stay rooted in accepted-risk and exception truth. That means the package should summarize `accepted_risks` section output and underlying `FindingException` or `FindingExceptionDecision` validity, owner, approval, expiry, and follow-up semantics without broadening into a new decision framework.
- Review-pack availability should be derived from current review-pack truth, signed-download readiness, expiry, and artifact-truth state. It should not become a new persisted package lifecycle.
- Evidence and stored-report basis should remain distinct from the package summary. Stored reports can remain subordinate evidence carriers behind existing evidence-source seams; the package may summarize their role, but it should not surface raw report payloads or invent a new viewer if one is not already present.
- The default plan is render-time composition over released-review summary, sections, and current artifact truth. If implementation later proves that one tiny helper accessor is needed, it should stay inside existing review/resource helpers and must not become new persistence or a parallel package service framework.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: status messaging, management summary sections, action links, evidence and review-pack viewers, package-availability states, and audit labels
- **State layers in scope**: page, detail, URL-query, table/session restore
- **Audience modes in scope**: customer/read-only, customer-admin, auditor-read-only, operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: collapsed and capability-gated on reused evidence or review-pack paths; stored-report detail remains secondary and only where an existing viewer seam already exists
- **One-primary-action / duplicate-truth control**: the workspace keeps `Open released review` as the sole dominant row action; released-review detail keeps one dominant package action; the workspace states readiness while detail owns the package summary so equal-priority duplicate truth is avoided
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract, bounded-browser-smoke
- **Exception path and spread control**: none planned; any proposal for a new package artifact, new report viewer, new branding or profile engine, or package-generation workflow becomes exception-required drift
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ReviewPackDownloadController`, `ArtifactTruthPresenter`, existing evidence-source seams, localization copy, `WorkspaceAuditLogger`, and `AuditActionId`
- **Shared abstractions reused**: existing review summary and section payloads, the shared `control_interpretation` contract, `ArtifactTruthPresenter` plus envelope/compression seams, current signed review-pack download generation, capability helpers, and the shared audit logger
- **New abstraction introduced? why?**: none planned. If implementation needs a tiny package-view accessor, it should stay inside the existing tenant-review or review-pack family instead of becoming a new packaging framework
- **Why the existing abstraction was sufficient or insufficient**: existing review, interpretation, review-pack, evidence, and artifact-truth seams already describe what the package should say and whether its supporting artifacts are usable. What is missing is the bounded product contract over those seams, not a new domain model or engine
- **Bounded deviation / spread control**: none planned. If a real stored-report viewer gap or profile-variant need appears, record it as a follow-up instead of adding a local workaround that becomes permanent architecture
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: read-only workspace and released-review detail rendering only; the management-ready path must not enqueue review-pack, stored-report, or evidence generation
- **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**: stored-report types, evidence dimension keys, and Microsoft-shaped source detail remain provider-owned inputs inside the existing evidence-source and report layers
- **Platform-core seams**: `governance package`, `released review`, `accepted risk`, `governance decision`, `evidence basis`, management-ready summary wording, and package-availability meaning
- **Neutral platform terms / contracts preserved**: `governance package`, `released review`, `review pack`, `evidence basis`, `accepted risk`, `governance decision`, and `review context`
- **Retained provider-specific semantics and why**: provider-specific labels may remain visible only in entitled secondary detail where an existing viewer already exposes them. They must not become default package vocabulary
- **Bounded extraction or follow-up path**: `document-in-feature` for bounded wording and truth-separation notes; `follow-up-spec` only if later work demands framework-specific package profiles or a real stored-report viewer surface
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The slice consumes existing released reviews, evidence snapshots, stored-report-backed evidence, and review-pack artifacts as read-only truth.
- Read/write separation: PASS. No new mutation, publication, regeneration, schedule, batch, or destructive path is introduced.
- Graph contract path: PASS. No new Graph call or provider contract work is part of this feature.
- Deterministic capabilities: PASS. Existing capability registries and role maps remain canonical.
- Workspace and tenant isolation: PASS. Workspace membership and tenant entitlement remain `404` boundaries.
- RBAC-UX plane separation: PASS. Everything stays inside the existing `/admin` plane and current tenant-scoped detail/proof reuse.
- Destructive confirmation standard: PASS by non-use. The package path introduces no destructive action, and existing operator-only destructive lifecycle actions remain unchanged outside this slice.
- Global search safety: PASS. `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` are already globally disabled, and no new searchable surface is introduced.
- OperationRun / Ops-UX: PASS by non-use. The management-ready path must not call `generateFromReview()` or start any report/evidence generation workflow.
- Data minimization: PASS. Default-visible content stays management-ready and customer-safe; raw reports, provider detail, and support diagnostics remain secondary.
- Test governance (TEST-GOV-001): PASS. Planned proof stays inside focused unit/feature coverage plus one bounded browser smoke.
- Proportionality / no premature abstraction: PASS. The package stays a derived contract over existing review, interpretation, pack, and artifact truth instead of introducing new persistence, engines, or registries.
- Persisted truth (PERSIST-001): PASS. No new table, entity, or artifact family is planned.
- Behavioral state (STATE-001): PASS. `available`, `partial`, `unavailable`, `expired`, and `blocked` conditions remain derived from existing review-pack and artifact truth, while stale or entitlement-restricted situations remain encoded as reasons within those states rather than as a new package lifecycle.
- Canonical package-state mapping: current repo truth `publishable` maps to `available`; `internal_only` or `stale` with still-readable basis maps to `partial`; `blocked`, `missing_input`, or source-not-publishable cases map to `unavailable`; `historical_only` maps to `expired`; package-level entitlement restriction maps to `blocked`. Secondary proof restrictions stay on `SupportingArtifactLink` disclosure and do not force the package summary itself into `blocked` when the review remains readable. Repo directions like `usable` or `follow_up_needed` stay inside supporting-artifact or governance-follow-up disclosure rather than creating extra package states.
- UI semantics / shared pattern first / Filament-native UI: PASS. Native Filament surfaces and existing shared review/artifact seams remain the default path.
- Provider boundary (PROV-001): PASS. Provider-specific semantics stay inside current source artifacts and do not become platform-core package truth.
- Filament / Laravel planning contract: PASS. Filament remains v5 on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no panel change is planned, no global-search change is planned, and no new assets are expected.
**Gate evaluation**: PASS.
- The narrow path is defensible if the implementation keeps package delivery anchored to the current released review and current review-pack download seam.
- The plan fails the gate if it drifts into new package persistence, scheduled generation, a new report engine, or a new stored-report viewer shell.
**Post-design re-check**: PASS once [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), and [contracts/governance-service-packaging.openapi.yaml](contracts/governance-service-packaging.openapi.yaml) are present and the agent-context refresh step is executed.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for any review-summary composition adjustments in the existing tenant-review composer seam; Feature for workspace readiness, released-review detail package summary, package/download availability, audit metadata, truth separation, and deny-as-not-found or in-scope capability boundaries; Browser for one bounded workspace-to-detail-to-package smoke path
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the repo already has focused unit and feature files covering the exact review, pack, evidence, and audit seams this feature will reuse, and one existing browser smoke is enough to catch rendered disclosure and action-hierarchy regressions without creating a new heavy family
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantReview/TenantReviewComposerTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/TenantReview/TenantReviewExecutivePackTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, released review, current export review pack, evidence snapshot, stored-report-backed evidence items, finding exception decision, and audit fixtures
- **Expensive defaults or shared helper growth introduced?**: no; any new helper should stay explicit and local to the review/review-pack family
- **Heavy-family additions, promotions, or visibility changes**: none beyond the already-existing single browser smoke
- **Surface-class relief / special coverage rule**: standard-native-filament relief on the workspace page, shared-detail-family coverage on released-review detail and package reuse
- **Closing validation and reviewer handoff**: rerun the commands above, verify the package path never starts export generation, verify current review-pack reuse and signed download behavior, verify accepted-risk or exception truth stays narrowed to current governance decisions, verify missing supporting access resolves as calm unavailable state or direct-route denial, and verify out-of-scope targets stay `404`
- **Budget / baseline / trend follow-up**: none expected beyond small feature-local additions in the existing suites
- **Review-stop questions**: lane fit, hidden fixture growth, accidental run generation, stored-report viewer drift, packaging abstraction creep
- **Escalation path**: `document-in-feature` for contained metadata or copy notes; `follow-up-spec` for a real stored-report viewer or broader package-profile need; `reject-or-split` for any drift into new package persistence, report engines, schedules, or campaigns
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: this feature is already the bounded packaging follow-up after Specs 258 and 259. Only broader profile automation, stored-report viewer productization, or multi-tenant packaging would justify a separate follow-up
## Rollout & Risk Controls
- Keep the canonical entry surface on the existing customer review workspace and the canonical secondary surface on the existing released-review detail route.
- Keep package delivery anchored to current released-review truth and current review-pack reuse. Do not trigger `exportExecutivePackAction()` or `ReviewPackService::generateFromReview()` from the customer-workspace path.
- Treat `publishable` review-pack truth as `available`, stale or internal-only but still-readable truth as `partial`, missing or blocked source truth as `unavailable`, historical-only truth as `expired`, and package-level entitlement restriction as `blocked`. If only supporting proof is restricted, keep the package summary readable and mark the affected supporting link unavailable or forbidden instead. Do not generate missing truth and do not imply calmness where the source basis is incomplete.
- Keep branding and profile variants deferred from v1. Neutral governance-package framing must not introduce a new presentation-truth source, layout system, or profile configuration seam.
- Keep stored-report detail subordinate. If implementation cannot point to an existing entitled viewer seam, the package should stop at calm evidence-basis disclosure rather than inventing a new route.
- Keep global search posture, provider registration, and asset strategy unchanged.
## Project Structure
### Documentation (this feature)
```text
specs/260-governance-service-packaging/
├── checklists/
│ └── requirements.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── governance-service-packaging.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Reviews/
│ │ │ └── CustomerReviewWorkspace.php
│ │ └── Resources/
│ │ ├── TenantReviewResource.php
│ │ ├── TenantReviewResource/Pages/ViewTenantReview.php
│ │ └── EvidenceSnapshotResource.php
│ ├── Http/Controllers/
│ │ └── ReviewPackDownloadController.php
│ ├── Models/
│ │ ├── TenantReview.php
│ │ ├── ReviewPack.php
│ │ ├── EvidenceSnapshot.php
│ │ ├── StoredReport.php
│ │ └── FindingException.php
│ ├── Services/
│ │ ├── Audit/WorkspaceAuditLogger.php
│ │ ├── Evidence/Sources/
│ │ │ ├── EntraAdminRolesSource.php
│ │ │ └── PermissionPostureSource.php
│ │ ├── ReviewPackService.php
│ │ └── TenantReviews/
│ │ ├── TenantReviewComposer.php
│ │ └── TenantReviewSectionFactory.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ ├── Auth/Capabilities.php
│ │ ├── Governance/Controls/ComplianceEvidenceMappingV1.php
│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
├── bootstrap/providers.php
├── lang/
│ ├── de/localization.php
│ └── en/localization.php
├── resources/views/filament/pages/reviews/customer-review-workspace.blade.php
└── tests/
├── Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php
├── Feature/Evidence/
├── Feature/ReviewPack/
├── Feature/Reviews/
├── Feature/TenantReview/
└── Unit/TenantReview/TenantReviewComposerTest.php
```
**Structure Decision**: Laravel monolith. The implementation should stay inside the existing `apps/platform` review, review-pack, evidence, audit, localization, and shared-truth seams, with no new panel/provider location and no new persistence layer.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None expected at planning time | The intended implementation is a bounded product contract over existing review, pack, evidence, and audit seams | Adding a `GovernancePackage` model, report engine, schedule, campaign flow, or new viewer shell would import unnecessary permanent complexity |
## Proportionality Review
- **Current operator problem**: operators still have to assemble stakeholder-ready governance packages manually outside the product, which risks drift from shared interpretation and artifact truth.
- **Existing structure is insufficient because**: current review workspace and released-review detail surfaces explain one released review, but they do not yet present one explicit management-ready package contract over the same truth and current package-download seam.
- **Narrowest correct implementation**: tighten the existing workspace and released-review detail flow so package readiness, summary, and current review-pack download reuse are explicit and consistent, while leaving generation, scheduling, and broader export orchestration out of scope.
- **Ownership cost created**: bounded copy and disclosure maintenance on existing surfaces, focused audit-metadata reuse, and small expansions in existing test files.
- **Alternative intentionally rejected**: a standalone `GovernancePackage` model, a package-specific report engine, a customer portal, a stored-report viewer shell, or scheduled campaign packaging were rejected because the repo already has the required review, interpretation, review-pack, evidence, and audit seams for a smaller v1.
- **Release truth**: current-release commercial packaging follow-through after Specs 258 and 259, not speculative future infrastructure.
## Phase 0 — Research (output: research.md)
Research resolves the remaining implementation-shaping decisions:
- keep the existing customer review workspace and released-review detail route as the only primary and secondary surfaces
- prefer current review-pack reuse and signed download over creating a new exported artifact or starting generation from the management-ready path
- derive package summary from existing review summary and section truth rather than a parallel package record or report engine
- keep `open decisions` narrowed to accepted-risk and exception decision truth only
- reuse artifact-truth seams for availability, expiry, and artifact/result truth separation
- treat stored reports as subordinate source truth and explicitly defer inventing a new viewer where the repo does not already expose one
- reuse existing audit events and metadata rather than introducing a new audit family
- keep validation inside existing unit/feature families plus the single existing browser smoke
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow packaging shape:
- no new table, package artifact family, or report namespace; package truth remains derived from existing reviews, sections, packs, evidence snapshots, stored reports, and governance decisions
- one conceptual package-summary contract documents the derived management-ready payload without prescribing new persistence
- the conceptual contract documents the existing workspace, released-review detail, review-pack download, and evidence routes reused by this feature
- quickstart records the intended implementation order, targeted tests, Filament v5 plus Livewire v4 posture, unchanged provider-registration location, unchanged global-search posture, unchanged destructive-action posture for the package path, and unchanged asset strategy
- `.specify/scripts/bash/update-agent-context.sh copilot` must run after the design artifacts are generated, even if it results in no technology additions
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/governance-service-packaging.openapi.yaml](contracts/governance-service-packaging.openapi.yaml)
- [quickstart.md](quickstart.md)
## Phase 2 — Planning (for tasks.md)
Dependency-ordered outline for the later `tasks.md` step:
1. Tighten the workspace page and intro copy so package readiness and management value are visible without adding a competing package row action.
2. Tighten the released-review detail flow in customer-workspace mode so it owns the management-ready package summary and keeps only one dominant package action.
3. Reuse the current review summary, interpretation, risk, and section seams to keep package wording aligned across workspace, detail, and supporting artifact access.
4. Reuse current review-pack truth, signed download generation, and artifact-truth presenter output for availability, expired, blocked, and partial states.
5. Keep stored-report-backed evidence subordinate to the package summary, surfacing explicit unavailable or secondary access states rather than inventing a new viewer.
6. Reuse the shared audit path and current capabilities, adding only bounded metadata or assertions if the current events do not already cover the required moments.
7. Expand the focused review, review-pack, evidence, and audit suites plus the single browser smoke, without introducing a new heavy family.
## Planning Guardrail Notes
- Planning guardrail result: PASS. Filament remains v5 on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel/provider is expected, no global-search expansion is planned, no new destructive action is introduced on the package path, and no new asset bundle is planned.
- Shared-path result: the plan keeps package meaning and availability on existing review, interpretation, review-pack, evidence, and artifact-truth seams rather than a new package engine.
- Stored-report tension result: the repo clearly has stored-report-backed evidence inputs but no obvious shared stored-report viewer resource in the current operator plane. The plan therefore keeps stored reports as subordinate source truth and defers any new viewer shell unless implementation proves an existing viewer seam already exists.
- Preparation workflow result: the existing [checklists/requirements.md](checklists/requirements.md) already carries the required review outcome and workflow outcome conventions, so no new checklist was needed during this plan pass.

View File

@ -1,53 +0,0 @@
# Quickstart — Governance-as-a-Service Packaging v1
## Preconditions
- Docker is running and the Sail stack for `apps/platform` is available.
- The feature stays inside the existing Laravel monolith and current admin plane.
- Filament remains v5 on Livewire v4.
- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); no provider or panel change is part of this work.
- No new `GovernancePackage` persistence family, no new report engine, no new schedule or batch path, no new `OperationRun`, no new stored-report viewer shell, no global-search expansion, and no asset strategy change are in scope.
- The management-ready path must reuse released-review truth plus existing review-pack, evidence, stored-report-backed evidence, and governance-decision truth.
## Intended Implementation Order
1. Review the current workspace, released-review detail, tenant-review composition, review-pack download, artifact-truth, evidence-source, capability, audit, and localization seams so the implementation stays on one shared path.
2. Confirm that package readiness on the workspace can be expressed from current review summary and artifact-truth semantics without introducing a competing package row action.
3. Confirm that the released-review detail surface in customer-workspace mode remains read-only and continues to expose only the current signed package-download seam instead of starting export generation.
4. Tighten [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) and its Blade intro so package readiness, management value, evidence basis, and calm unavailable states are visible at the right disclosure level.
5. Tighten [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php) and [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) so the released-review detail owns the management-ready summary and keeps one dominant package action.
6. Reuse existing `TenantReview.summary`, section payloads, and [../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php](../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php) for package wording instead of adding a package-local mapper.
7. Reuse [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) plus current review-pack truth for `available`, `partial`, `unavailable`, `expired`, and `blocked` states rather than a new package state family. Use the canonical mapping `publishable -> available`, `internal_only` or `stale -> partial`, `blocked` or `missing_input -> unavailable`, `historical_only -> expired`, and package-level entitlement restrictions -> `blocked`. If only supporting proof is restricted, keep the package summary readable and mark the affected secondary link unavailable or forbidden. Stale or entitlement-restricted conditions must map to reason codes inside those states, not to a second availability taxonomy.
8. Keep stored reports subordinate to evidence basis; only link to secondary detail when an existing entitled viewer seam already exists.
9. Reuse current audit events and current capability checks for package access, package download, and proof access.
10. Expand only the existing unit, feature, and smoke suites listed below, then run the targeted tests and Pint.
## Targeted Validation Commands (after implementation)
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantReview/TenantReviewComposerTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/TenantReview/TenantReviewExecutivePackTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Planned Smoke Checklist (after implementation)
1. Sign in to `/admin` as an actor with workspace scope and open `/admin/reviews/workspace`.
2. Confirm only entitled tenants appear and that package readiness is shown as information, not as a second competing row action.
3. Confirm the workspace exposes calm management-ready cues built from current review truth, including current evidence basis and current recommended next action.
4. Open a released review and confirm the detail surface owns the package summary and exposes the current package action without showing operator-only export actions.
5. Confirm the package path reuses current review-pack truth and does not trigger generation or a new `OperationRun` when supporting artifacts are missing.
6. Confirm accepted-risk entries stay distinct from governance-decision follow-up entries, remain bounded to current governance truth, and do not read like a broader decision inbox.
7. Drill into supporting proof where entitled and confirm raw payloads and support-only diagnostics remain secondary.
8. Attempt an out-of-scope tenant or review target and confirm the response remains not found without leaking package, review, or artifact presence.
## Notes
- This is a preparation-only package. No application implementation or validation results belong in this planning artifact yet.
- Filament remains v5 on Livewire v4.
- Provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) with no change expected.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) remain globally disabled; this slice does not change their search posture.
- No destructive, remediation, publication, generation, or provider-changing action belongs on the management-ready package path.
- No branding, profile-variant, or client-specific layout system belongs in v1; `Download governance package` remains neutral framing over the current export review pack only.
- No new Filament assets are expected. If later implementation unexpectedly registers assets, deployment still requires `cd apps/platform && php artisan filament:assets`, but this package does not plan such a change.
- The plan assumes current review-pack reuse is the default delivery seam. If implementation proves that no current pack exists for an otherwise eligible released review, the correct v1 behavior is an explicit unavailable or partial state, not a new generation workflow.
- Stored reports stay subordinate source truth. If no current entitled viewer seam exists, the package should stop at truthful evidence-basis disclosure rather than inventing a new route.

View File

@ -1,141 +0,0 @@
# Research — Governance-as-a-Service Packaging v1
**Date**: 2026-05-01
**Spec**: [spec.md](spec.md)
This document resolves the planning decisions for the smallest safe management-ready package over one released review context.
## Decision 1 — Keep the existing customer review workspace and released-review detail flow as the only primary and secondary surfaces
**Decision**: Reuse [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) as the primary decision surface and [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) in customer-workspace mode as the only secondary context surface.
**Rationale**:
- The repo already has the admin-plane route, tenant-safe prefilter behavior, package-readiness-adjacent columns, and bounded feature and browser coverage for these surfaces.
- The missing product contract is management-ready packaging over existing truth, not new routing, a new panel, or a customer portal shell.
- Reusing these surfaces keeps the package anchored to one released review context and prevents a second package registry or dashboard.
**Alternatives considered**:
- Add a dedicated package page or customer-only package portal.
- Rejected: duplicates the current review-consumption path and widens scope into shell-level IA and identity concerns.
- Put all management-ready content on the workspace only.
- Rejected: would overload the list surface and weaken the existing released-review drilldown as the package-owning context.
## Decision 2 — Prefer current review-pack reuse and signed download over creating a new exported artifact or generation path
**Decision**: Treat the current export review pack and its signed download route as the default package-delivery seam for v1. The management-ready path should reuse `downloadCurrentReviewPackAction()` and current review-pack truth before considering any new artifact concept.
**Rationale**:
- In customer-workspace mode [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) already exposes `downloadCurrentReviewPackAction()` and suppresses operator lifecycle actions.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already provides signed download URLs, while `generateFromReview()` is tied to the existing export workflow and `OperationRun` lifecycle.
- The feature spec explicitly forbids package generation runs and a new package artifact family. Reusing current review-pack truth is therefore the narrowest correct path.
**Alternatives considered**:
- Add a new `GovernancePackage` artifact or export namespace.
- Rejected: creates parallel persistence and new lifecycle semantics the spec explicitly forbids.
- Start `generateFromReview()` from the management-ready package action when no pack exists.
- Rejected: introduces a queued generation path and breaks the explicit no-`OperationRun` scope boundary.
## Decision 3 — Derive package meaning from current review summary and section truth, not from a parallel package record
**Decision**: Compose package meaning from existing released-review summary and section payloads, especially `summary.control_interpretation`, `summary.highlights`, and the current section family created by [../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php](../../apps/platform/app/Services/TenantReviews/TenantReviewComposer.php) and [../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php](../../apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php).
**Rationale**:
- The repo already persists the management-relevant building blocks needed for this slice inside the released review artifact.
- Existing sections already cover executive summary, control interpretation, open risks, accepted risks, permission posture, baseline drift, and operations health.
- Reusing those payloads keeps workspace, detail, and package access on one truth path and avoids a second semantic layer.
**Alternatives considered**:
- Persist a package-specific JSON blob or summary projection.
- Rejected: adds a new source of truth for content already represented by the released review.
- Compose package content directly from raw findings, evidence payloads, and stored reports in page code.
- Rejected: bypasses the shared interpretation path and increases drift risk.
## Decision 4 — Keep Spec 259s shared interpretation layer mandatory and keep “open decisions” narrowed to risk-acceptance / exception truth
**Decision**: Package content must remain dependent on [../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php](../../apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php) and on the reviews existing `control_interpretation` payload. The roadmap phrase `open decisions` remains narrowed to accepted-risk and exception decision truth only.
**Rationale**:
- The spec requires the package to stay on the shared customer-safe interpretation path and explicitly forbids raw finding or report language as the packages meaning layer.
- `ComplianceEvidenceMappingV1` already includes accepted-risk follow-up wording and limitation semantics that fit the packages calm disclosure goal.
- Narrowing `open decisions` to existing exception and risk-acceptance truth keeps scope out of queue or workflow-engine territory.
**Alternatives considered**:
- Invent a package-local management summary mapper.
- Rejected: would fork the current interpretation contract and create inconsistent wording across workspace, detail, and package access.
- Generalize `open decisions` into a broader governance inbox or approval board.
- Rejected: imports a new workflow engine and wider product semantics beyond the current slice.
## Decision 5 — Reuse artifact-truth seams for availability, expiry, and truth separation
**Decision**: Reuse [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) for current review, evidence snapshot, and review-pack availability or expiry semantics, and keep artifact/result truth separation explicit in the package contract.
**Rationale**:
- `ArtifactTruthPresenter` already resolves envelopes for `TenantReview`, `EvidenceSnapshot`, and `ReviewPack` and already carries compressed truth about artifact existence, readiness, and reasons.
- The package spec explicitly requires artifact/result truth separation: the package summary must not redefine review, pack, evidence, or report truth.
- Reusing the presenter keeps package availability semantics aligned with adjacent governance artifact surfaces.
**Alternatives considered**:
- Add a new package availability enum or presenter family.
- Rejected: redundant with current artifact-truth seams and disproportionate for this slice.
- Collapse review truth, review-pack truth, and evidence truth into one package status.
- Rejected: violates the repos operator-surface guidance around distinct truth dimensions.
## Decision 6 — Keep stored reports subordinate source truth and do not invent a new viewer shell during v1 planning
**Decision**: Treat stored reports as existing evidence inputs and secondary drilldown only where the repo already exposes them through current seams. Do not introduce a new stored-report viewer route or shell during v1 planning.
**Rationale**:
- The repo clearly has stored-report-backed evidence inputs through seams such as [../../apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php](../../apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php) and [../../apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php](../../apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php).
- A shared stored-report viewer resource is not obvious in the current operator plane, so inventing one here would exceed the slice and introduce a new surface family.
- The package can still remain truthful by summarizing stored-report-backed evidence basis and marking supporting detail unavailable when no existing entitled viewer seam exists.
**Alternatives considered**:
- Create a new stored-report detail surface as part of packaging.
- Rejected: adds a new viewer family not required to ship one management-ready package.
- Ignore stored-report-backed evidence entirely.
- Rejected: would understate the evidence basis and make the package less faithful to the existing review truth.
## Decision 7 — Reuse current audit events and metadata instead of introducing a new audit family
**Decision**: Keep package auditability on the existing `customer_review_workspace.opened`, `tenant_review.opened`, `review_pack.downloaded`, and `evidence_snapshot.opened` events, enriching metadata before introducing any new audit concept.
**Rationale**:
- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) already defines the relevant action IDs for the package path.
- Workspace and released-review entry points already record interpretation-version and source-surface context, and review-pack downloads already flow through a shared signed route.
- The spec requires auditability, not a new audit store or family.
**Alternatives considered**:
- Create a new `governance_package.opened` event family up front.
- Rejected: not justified until the feature proves it needs an explicit open event beyond current review-open and pack-download moments.
- Rely on passive render without audit coverage.
- Rejected: weakens attributable access semantics for a stakeholder-ready deliverable.
## Decision 8 — Defer branding and profile variants from v1 and keep neutral localization-ready package framing
**Decision**: V1 does not introduce MSP branding, profile variants, or client-specific layouts. `Download governance package` remains neutral governance-package framing over the current export review pack for the released review, and new labels stay in the existing DE/EN localization posture.
**Rationale**:
- Repo exploration did not reveal a clear existing branding source-of-truth that this slice could safely reuse without hidden configuration or persistence work.
- The spec only needs one repeatable management-ready package path; it does not need brand customization to prove product fit.
- Deferring branding avoids accidental profile-variant, layout, or package-template framework growth.
**Alternatives considered**:
- Add bounded package branding now.
- Rejected: no clear repo-real branding source-of-truth was confirmed, so this would import hidden scope.
- Hardcode package wording inline on page classes.
- Rejected: weakens localization-readiness and makes cross-surface wording drift more likely.
## Decision 9 — Keep validation in existing review, review-pack, evidence, and smoke test families
**Decision**: Expand the existing unit, feature, and single browser smoke files that already cover the review, pack, evidence, and audit seams touched by this feature.
**Rationale**:
- The repo already has focused tests for workspace behavior, released-review explanation, review-pack download, entitlement enforcement, evidence audit logging, and the bounded workspace smoke path.
- Those files are the cheapest honest proof for package reuse, scope isolation, calm unavailable states, and truth separation.
- Reusing them honors the specs test-governance requirement to avoid a broader heavy family.
**Alternatives considered**:
- Add a new package-specific browser suite.
- Rejected: too expensive for the bounded value of this slice.
- Rely only on unit tests around new helpers.
- Rejected: the features core contract lives on rendered Filament surfaces and needs surface-level proof.

View File

@ -1,400 +0,0 @@
# Feature Specification: Governance-as-a-Service Packaging v1
**Feature Branch**: `260-governance-service-packaging`
**Created**: 2026-05-01
**Status**: Draft
**Input**: User description: "Prepare Governance-as-a-Service Packaging v1 as the smallest viable on-demand management-ready governance package for one released review context, built from existing review packs, evidence snapshots, stored reports, findings, accepted-risk truth, and the shared compliance interpretation layer, without adding a new panel, report engine, scheduling system, or parallel package domain."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has repo-real tenant reviews, review packs, evidence snapshots, stored reports, accepted-risk governance, and customer-safe review surfaces, but operators still lack one repeatable management-ready deliverable that packages that truth for MSP and customer stakeholders.
- **Today's failure**: Operators still rely on manual screenshot decks, spreadsheet extracts, or ad-hoc summaries to explain released review truth. That manual packaging can bypass the customer-safe interpretation layer, weaken auditability, and drift away from the underlying review and artifact truth.
- **User-visible improvement**: An entitled actor can open one released review context and access one repeatable management-ready governance package that is customer-safe and management-readable by default.
- **Smallest enterprise-capable version**: One on-demand governance package for one released review context inside the current admin-plane review flow, derived from existing released review, review-pack, evidence, stored-report, finding, and exception or risk-acceptance truth plus the shared customer-safe interpretation layer from Spec 259. No new portal, no scheduling, no multi-pack automation, and no second source of truth ship in v1.
- **Explicit non-goals**: No new panel, no customer portal, no separate identity plane, no new report engine, no scheduling or batching system, no campaign workflow, no CRM or PSA handoff, no AI-generated summaries, no raw operator-data dump as the default deliverable, no broad multi-pack automation, and no standalone `GovernancePackage` domain.
- **Permanent complexity imported**: One bounded management-ready packaging contract over existing review and export surfaces, one bounded package-availability state on current review surfaces, focused feature plus bounded browser coverage, and explicit audit expectations for package access. No new table, no new panel, no new identity plane, and no campaign framework are introduced.
- **Why now**: `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/implementation-ledger.md` all keep this as an active open commercial follow-up, and Specs 258 and 259 explicitly defer it as the next bounded step after customer-safe review productization and shared interpretation are prepared.
- **Why not local**: A one-off PDF or export tweak or a single-page summary would either bypass the shared interpretation and artifact truth or remain too inconsistent across review, evidence, and review-pack surfaces to become a repeatable MSP deliverable.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New packaging vocabulary and multi-surface reuse. Defense: the slice remains fully derived from existing review, export, evidence, and interpretation truth; it introduces no new persistence or automation framework; it explicitly narrows the roadmap phrase `open decisions` to repo-real exception and risk-acceptance decision truth; and it defers branding or profile variants rather than inventing a new presentation-truth source.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- existing admin-plane customer review workspace at `/admin/reviews/workspace`
- existing tenant-scoped released review detail route for the selected review
- existing review-pack access or download surface reached from released review context
- existing evidence summary and proof routes reached from released review context when the actor is entitled
- **Data Ownership**: All visible truth remains derived from existing tenant-owned `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, `StoredReport`, `Finding`, `FindingException`, `FindingExceptionDecision`, and `AuditLog` records plus the shared customer-safe interpretation layer prepared by Spec 259. No new `GovernancePackage` table, no mirrored package artifact family, no campaign record, and no parallel report-store namespace are introduced. In v1, the downloadable artifact behind `Download governance package` is the current export review pack for the released review; the governance package summary is derived presentation over that existing artifact and related evidence truth, not a second export family. If an existing review or export artifact carries package output, it remains subordinate to the released review context and does not become independent source truth.
- **RBAC**:
- this remains an admin-plane follow-up, not a new panel or authorization plane
- workspace membership remains the first isolation boundary
- page entry requires an established workspace scope plus at least one entitled tenant and released review the actor may read through the current capability registry
- package access reuses existing review visibility, review-pack access, evidence or proof access, workspace entitlements, and current audit boundaries
- non-members and out-of-scope tenant or review targets resolve as deny-as-not-found
- actors inside an established scope may receive capability denial on the reused released-review detail route if they lack inherited review-view capability, and on gated secondary artifact or proof paths they are not entitled to use
- the package remains read-only and introduces no operator mutation or provider-changing action
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from tenant-scoped review, evidence, or review-pack context, the workspace prefilters to that tenant and foregrounds the latest released review for that tenant. Without incoming tenant context, the page shows only entitled tenants in the current workspace.
- **Explicit entitlement checks preventing cross-tenant leakage**: Aggregated package availability, released-review entry, review-pack access, evidence links, and any already-existing secondary proof drilldowns only resolve for tenants and released reviews the actor is already entitled to read in the current workspace. Inaccessible targets are omitted from aggregated lists and resolve as not found when directly targeted.
## 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)**: status messaging, management summary sections, action links, evidence viewers, review-pack access, package-availability states, and stored-report-backed evidence disclosure
- **Systems touched**: existing `CustomerReviewWorkspace`, existing released review detail surfaces, `TenantReviewSectionFactory`, `TenantReviewComposer`, the shared `control_interpretation` contract prepared by Spec 259, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, existing review-pack access or download surfaces, existing evidence summary and proof viewers, stored-report-backed evidence inputs, localization, and audit infrastructure
- **Existing pattern(s) to extend**: the current customer review productization contract from Spec 258, the shared customer-safe control or readiness interpretation path from Spec 259, and the existing review-pack, evidence, and artifact-truth disclosure surfaces
- **Shared contract / presenter / builder / renderer to reuse**: `TenantReviewSectionFactory`, `TenantReviewComposer`, the shared `control_interpretation` contract from Spec 259, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `SurfaceCompressionContext`, and the current review, evidence, stored-report-backed evidence, and review-pack disclosure surfaces
- **Why the existing shared path is sufficient or insufficient**: Existing review, interpretation, review-pack, evidence, stored-report, and audit truth already cover what the package should say. The missing piece is one repeatable management-ready packaging pass that keeps those truths aligned instead of forcing manual translation or page-local exports.
- **Allowed deviation and why**: none. This slice must tighten the current review and export path rather than introduce a parallel package domain, package-specific vocabulary layer, or direct raw-report export.
- **Consistency impact**: Executive summary wording, top findings, accepted-risk governance wording, governance-decision follow-up phrasing, evidence-link language, package-availability states, current review-pack framing, and audit labels must stay aligned between workspace summary, released review detail, and package access.
- **Review focus**: Reviewers must block any new standalone package domain, any page-local interpretation path, any raw data-dump default, or any management export that bypasses released review and shared interpretation truth.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: This slice packages only already released review truth and already available evidence, report, and review-pack inputs. It must not start new review-pack, stored-report, evidence, or package runs in v1.
- **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**: customer-safe interpretation reuse, management package wording, evidence-link semantics, package labels, and summary language
- **Neutral platform terms preserved or introduced**: governance package, released review, control or readiness summary, evidence basis, accepted-risk governance decision, and review context
- **Provider-specific semantics retained and why**: Provider-specific report types or evidence labels may remain reachable only through entitled secondary drilldown because some existing stored reports are provider-owned artifacts. They are not part of the default management-ready package language.
- **Why this does not deepen provider coupling accidentally**: Package summary and availability are derived from released review truth plus the shared customer-safe interpretation layer, not from raw Microsoft report types, provider identifiers, or provider payload language.
- **Follow-up path**: future framework-specific package profiles or broader portfolio packaging remain follow-up specs after this bounded v1 proves itself
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Customer Review Workspace page | yes | Native Filament page plus shared review and evidence primitives | status messaging, package availability, navigation entry points | page, table or filter state, disclosure state | no | Existing page stays the selection surface and does not become a separate package registry |
| Released Customer Review detail | yes | Native Filament resource or detail surface plus shared review, evidence, and export primitives | management summary sections, package access, evidence and report viewers | detail sections, disclosure state, access states | no | Existing detail becomes the package-owning context and avoids a new page shell |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | Primary Decision Surface | Operator decides which released review context is ready for stakeholder packaging | released review state, package availability, top-line governance signal, and evidence-basis presence | released review detail, package summary, and supporting proof after explicit open | Primary because it is the calm review-selection surface and should answer where packaging can begin before deeper inspection | Aligns packaging with the existing review-consumption workflow instead of with storage-object navigation | Removes hunting across review pack, stored report, and evidence screens before the first decision |
| Released Customer Review detail | Secondary Context Surface | Operator confirms what the package will say and accesses the package for one released review | executive summary, top findings, accepted risks, governance-decision follow-up, evidence links, and package state | deeper proof, stored-report context, and raw or support detail only after explicit drilldown | Secondary because it is entered after the workspace has already selected one review context | Keeps packaging centered on one released review instead of inventing a second package dashboard | Removes manual summary assembly by making the released review the single packaging anchor |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | operator-MSP, customer-admin, auditor-read-only, customer-read-only | released review state, package availability, calm executive-summary teaser, top findings and accepted-risk signal, and evidence-basis presence | review freshness, lineage, and source completeness only after opening the review | raw reports, provider IDs, fingerprints, and raw evidence remain hidden or gated | `Open released review` | raw or support detail and secondary artifact links stay off the list surface by default | Workspace states package readiness once; detail owns the actual package summary |
| Released Customer Review detail | operator-MSP, customer-admin, auditor-read-only, customer-read-only | management-ready executive summary, top findings, accepted risks, current governance decisions needing awareness, evidence links, package scope, current review-pack delivery framing, and package-access state | review lineage, freshness, section completeness, and source artifact detail in secondary sections only | raw evidence payloads, stored-report payloads, provider-debug context, and unrestricted support detail remain hidden or gated | `Download governance package` | raw source payloads, unrestricted stored-report detail, and operator-only diagnostics remain secondary and explicit | Detail owns the management package summary; deeper proof links add source context without restating the same summary cards as peer truth |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the released review for packaging context | full-row open to released review detail | required | no peer package row action; package availability stays informational on the list surface | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` | workspace context, tenant prefilter, release state, package availability | Customer review | whether the current released review is eligible for management-ready delivery | none |
| Released Customer Review detail | Detail / Report / Management export | Read-only detail report | Download the governance package for the current released review | sectioned detail page with one dominant safe header action | forbidden | existing proof links and review-pack access remain secondary in-body or lower-priority actions | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` | workspace, tenant, release state, interpretation version, package availability | Governance package | what this package covers, what matters now, and which evidence basis supports it | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace page | MSP operator or entitled customer-safe reviewer | Decide whether the current released review is ready to package and which review to open | Read-only workspace review overview | Which released review should I package for stakeholder delivery? | released review state, package availability, top-line findings or accepted-risk signal, and evidence-basis presence | review lineage, freshness, and source completeness in secondary context only | release state, package readiness, evidence completeness, accepted-risk governance signal | none | Open released review | none |
| Released Customer Review detail | MSP operator or entitled customer-safe reviewer | Inspect the management-ready summary and access the package for one released review | Read-only detail report | What will this stakeholder package say, and is the supporting basis ready and safe to deliver? | executive summary, top findings, accepted risks, governance-decision follow-up, evidence links, package state, and explicit current review-pack delivery framing | deeper stored-report and evidence provenance plus raw or support details only after explicit drilldown and entitlement | interpretation version, evidence sufficiency, accepted-risk governance validity, package availability | none | Download governance package | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no new artifact family
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Existing review truth can explain one released review, but it still does not provide one repeatable management-ready package that stays aligned with shared interpretation and artifact truth.
- **Existing structure is insufficient because**: Review workspace and detail flows answer review questions, but they do not yet provide a bounded deliverable contract for stakeholder packaging. Manual packaging currently re-translates the same truth outside the product.
- **Narrowest correct implementation**: One derived management-ready package bound to one released review context and current review, evidence, report, and export surfaces, with explicit unavailable states instead of generating missing source truth.
- **Ownership cost**: A bounded disclosure and wording pass plus focused package-access, authorization, and audit tests, and one bounded browser smoke.
- **Alternative intentionally rejected**: A standalone `GovernancePackage` model and campaign engine were rejected as structurally too heavy. A page-local export template that reads raw findings or reports directly was rejected because it would drift from shared interpretation and artifact truth.
- **Release truth**: current-release commercial follow-through, not future-release platform buildout
### 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 reuse of existing review and export truth is preferred over introducing a new package domain.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: Focused feature tests are the narrowest sufficient proof for package availability logic, interpretation dependency, auditability, `404` or `403` boundaries, explicit unavailable or partial states, and truth separation across the existing review surfaces. One bounded browser smoke remains justified because this slice materially changes default-visible management-ready content and action hierarchy on rendered review surfaces.
- **New or expanded test families**: expand the existing `Reviews/CustomerReviewWorkspace`, `TenantReview`, `ReviewPack`, and adjacent audit or package-access families; keep exactly one bounded browser smoke around the current review workspace and detail flow
- **Fixture / helper cost impact**: low to moderate. Reuse existing workspace membership, released review, review pack, evidence snapshot, stored report, finding, exception decision, interpretation summary, and audit fixtures. Avoid provider sync or queue-heavy defaults.
- **Heavy-family visibility / justification**: exactly one browser smoke stays explicit because the slice is primarily about packaging disclosure and action placement on existing rendered surfaces. No broader browser or heavy-governance family is introduced.
- **Special surface test profile**: shared-detail-family
- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for routing, authorization, availability, localization-ready wording, and package-truth separation. The bounded browser smoke is the only required rendered proof.
- **Reviewer handoff**: Reviewers must confirm that package access never bypasses the shared interpretation layer, no new package domain or search surface appears, out-of-scope tenants leak nothing, management summary stays customer-safe, `Download governance package` resolves to the current export review pack rather than a second artifact family, and no branding or profile system is smuggled into v1.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
## Scope Boundaries
### In Scope
- one on-demand management-ready governance package for one released review context
- executive summary in customer-safe language grounded in released review truth
- top findings, accepted risks, current governance decisions requiring awareness, and evidence links
- reuse of the shared customer-safe interpretation layer from Spec 259, which the package must not bypass
- package availability plus explicit `available`, `partial`, `unavailable`, `expired`, and `blocked` states, with stale or entitlement-restricted conditions expressed through those state reasons
- explicit current review-pack framing that does not introduce a second downloadable artifact family
- explicit auditability for package access or download and secondary evidence or proof opens
- localization-ready customer-safe and management-ready wording
- reuse of current review-pack, stored-report, evidence-snapshot, and review surfaces rather than a parallel package domain
### Non-Goals
- a new panel, portal, customer shell, or separate identity plane
- a new report engine, campaign system, schedule, batch flow, newsletter flow, or communications automation
- PSA or CRM workflow, service-desk handoff, or broader customer-ops orchestration
- review authoring, publishing, regeneration, or evidence or report refresh generation
- raw operator-data dumps as the default deliverable
- AI-generated summaries or packaging
- branding or profile variants beyond neutral v1 governance-package framing over the current export review pack
- multi-review or multi-tenant package automation
- a new governance-decision workflow state or broader decision board beyond repo-real exception and risk-acceptance truth
- a new standalone governance-package persistence family
## Dependencies
- current customer review productization contract from `specs/258-customer-review-productization/spec.md`
- shared customer-safe interpretation layer from `specs/259-compliance-evidence-mapping/spec.md`
- existing `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, `StoredReport`, `Finding`, `FindingException`, and `FindingExceptionDecision` truth
- existing review-pack access or download surfaces and evidence or report drilldown surfaces
- existing localization, entitlements, capability-first RBAC, and audit foundations
## Assumptions
- released review truth remains the authoritative context for stakeholder delivery
- the shared customer-safe interpretation layer from Spec 259 exists and is reused; packaging does not compute management meaning separately
- an eligible existing review, evidence, and report basis is available for at least one released review context; when it is not, v1 can show explicit unavailable or partial state instead of generating missing sources
- current Filament v5 plus Livewire v4 admin-plane surfaces remain the canonical entry points; no panel or provider registration change is required
- existing asset strategy is sufficient; this slice does not justify heavy new asset registration
- existing review-pack or download entitlement rules remain the governing commercial gate for artifact access
- branding or profile variants stay deferred until the repo has a clear existing source of presentation truth that does not add new persistence or configuration scope
## Risks
- packaging copy could drift from the shared interpretation layer if implementation reads raw findings or stored reports directly
- scope pressure could try to add client-specific branding, profile variants, or bespoke layouts even though v1 intentionally keeps neutral governance-package framing over the current export review pack only
- some released reviews may have incomplete evidence or report basis, forcing explicit partial package states
- the roadmap phrase `open decisions` could be over-read as a broader queue or approval engine unless the slice stays narrowed to repo-real exception or risk-acceptance decision truth
- if package access attempts to trigger missing review-pack or report generation, scope could spill into report-engine or `OperationRun` work
## Candidate Selection Rationale
- **Selected candidate**: Governance-as-a-Service Packaging v1
- **Source locations**:
- `docs/product/spec-candidates.md` active P2 candidate
- `docs/product/roadmap.md` priority order item 5 plus the dedicated packaging section
- `docs/product/implementation-ledger.md` open gap `Review truth is not yet packaged as a repeatable MSP deliverable`
- `specs/258-customer-review-productization/spec.md`
- `specs/259-compliance-evidence-mapping/spec.md`
- **Why selected**: This is the active unspecced P2 candidate with no direct existing spec. Specs 258 and 259 explicitly defer it as the next bounded follow-up, and the roadmap order plus implementation ledger both place it in the current sellability lane.
- **Why this is the smallest viable implementation slice**: It keeps the work on one on-demand management-ready package for one already released review context, using current review, evidence, report, and export artifacts plus shared customer-safe interpretation, with no new package domain, campaign engine, or scheduling system.
- **Intentional narrowing from source candidate**: The roadmap and candidate phrase `open decisions` is narrowed here to repo-real governance decision truth from exceptions and risk acceptance, especially current accepted-risk governance decisions and exception decisions that still need follow-up or contextual explanation. V1 does not invent a broader decision queue, approval workflow, or new operator inbox semantics, and it defers bounded MSP branding until the repo has a clear existing source of presentation truth to reuse.
- **Why close alternatives are deferred**:
- Cross-Tenant Compare and Promotion v1 already has refreshed Spec 043 and remains a separate portfolio-action track.
- Workspace, Tenant & Managed Object Lifecycle Governance v1 is explicitly strategic and not ready in the active candidate queue.
- External Support Desk / PSA Handoff already has Spec 256 and is a separate commercialization lane.
- Customer Review Productization and Compliance Evidence Mapping already have Specs 258 and 259; this package depends on them instead of reopening them.
## Follow-up Candidates
- recurring schedule, batch, or campaign packaging only after one on-demand package proves product fit and stays audit-safe
- broader branding or profile variants only after the default package remains truthful across customers and the repo has a clear existing presentation-truth source to reuse
- portfolio or multi-tenant package automation after compare or promotion and governance decision convergence are materially closed
- external delivery workflows such as PSA, service-desk, or communications automation as separate follow-ups
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Produce one repeatable stakeholder package from a released review (Priority: P1)
An MSP operator wants one package from a released review so they can deliver a recurring governance update without manual slide decks, spreadsheet extracts, or ad-hoc screenshots.
**Why this priority**: This is the core sellability gap. If the operator still has to assemble the management story outside the product, the feature fails.
**Independent Test**: Open an eligible released review and access the package that is built from existing review, evidence, report, and review-pack basis without triggering a new reporting or generation workflow.
**Acceptance Scenarios**:
1. **Given** an entitled actor has a released review with an eligible existing evidence and report basis, **When** they open the released review detail, **Then** they can access one management-ready governance package without starting new review, evidence, or report generation.
2. **Given** no eligible released review basis exists, **When** the actor requests the package, **Then** the surface shows an explicit unavailable or partial state instead of a hidden admin path or a new generation workflow.
3. **Given** the actor opens the workspace with multiple entitled tenants, **When** they scan the review list, **Then** they can identify which released review context is package-ready before opening detail.
---
### User Story 2 - Read management-ready governance meaning without raw operator translation (Priority: P1)
A customer admin or auditor wants the package to explain what matters now in calm management-ready terms so they can understand the governance position without reading raw operator detail.
**Why this priority**: Packaging is not useful if it still requires manual translation of technical findings and evidence into stakeholder language.
**Independent Test**: Open the package or its access context for one released review and confirm that the output summarizes executive position, top findings, accepted risks, governance decisions, and evidence links using shared customer-safe interpretation.
**Acceptance Scenarios**:
1. **Given** the package is available, **When** the actor opens it or its access context, **Then** the output summarizes executive position, top findings, accepted risks, current governance decisions requiring awareness, and evidence links in customer-safe language.
2. **Given** the shared interpretation layer marks a control, readiness summary, or package section as partial, **When** the package is shown, **Then** the package keeps that limitation visible and does not infer stronger management claims from raw evidence.
3. **Given** the actor stays in the default package view, **When** deeper stored reports or raw evidence exist, **Then** raw payloads and operator or support diagnostics remain hidden until explicit entitled drilldown.
---
### User Story 3 - Trust packaging boundaries, auditability, and scope isolation (Priority: P2)
An MSP operator wants the package to stay auditable and safe even when secondary access paths are restricted, so stakeholder delivery does not weaken trust or isolation.
**Why this priority**: A management package only helps if it remains attributable, bounded, and safe to expose across tenant and workspace boundaries.
**Independent Test**: Access or download the package for an entitled review, then verify auditability, explicit secondary-access unavailability, current review-pack delivery framing, and deny-as-not-found behavior for out-of-scope targets.
**Acceptance Scenarios**:
1. **Given** the package is accessed or downloaded, **When** the action completes, **Then** the audit trail records that access using the current audit infrastructure.
2. **Given** the actor lacks a secondary review-pack or proof-access entitlement inside an otherwise visible review context, **When** the package renders, **Then** the management summary remains readable while secondary artifact access is explicitly unavailable.
3. **Given** the actor targets a tenant or review outside their scope, **When** the package or supporting link is opened, **Then** the system resolves as not found and reveals no package, review, or evidence presence.
### Edge Cases
- a released review exists but the shared interpretation layer or version is unavailable for one section, so the package must show explicit partial or unavailable content and no raw-report fallback
- accepted risk exists but current governance is expired, revoked, or rejected, so the package must show governance follow-up needed rather than treat the issue as safely accepted
- a review pack exists but a supporting stored report or evidence link has expired or been redacted, so the package must keep the summary truthful and mark the supporting link unavailable
- a workspace filter or direct link targets an inaccessible tenant or unreleased review, so the request must resolve safely without leakage
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no tenant-changing write path, no new scheduled work, and no new `OperationRun`. It packages already released review truth and already available evidence or report inputs for stakeholder delivery.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This follow-up must stay derived. It must not introduce a standalone package source of truth, a new report engine, a campaign system, or a new package-specific taxonomy.
**Constitution alignment (XCUT-001):** The feature must extend existing review, interpretation, review-pack, evidence, report, localization, and audit paths rather than invent a package-local semantic layer.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Default-visible content must remain customer-safe and management-ready, with raw or support detail hidden by default and one dominant next action preserved per surface.
**Constitution alignment (TEST-GOV-001):** The implementation must stay with focused feature coverage plus one bounded browser smoke and avoid creating a broader heavy family.
**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries. Optional secondary artifact access remains capability-gated inside an established scope. No new role strings or raw capability strings are introduced by this spec.
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The slice must remain a native Filament read-only review and export flow with one dominant inspect action on the workspace and one dominant package action on released review detail. No destructive actions are introduced.
### Functional Requirements
- **FR-001**: The system MUST keep this slice inside the existing admin-plane customer review workspace and released-review detail flow rather than creating a new panel, portal, or customer shell.
- **FR-002**: The system MUST derive every management-ready package summary from existing released review, review-pack, evidence-snapshot, stored-report, finding, accepted-risk, and exception-decision truth rather than from a new standalone package record.
- **FR-003**: The package MUST depend on the shared customer-safe interpretation layer prepared by Spec 259 and MUST NOT render management meaning directly from raw findings, stored reports, or evidence payloads when that layer is absent.
- **FR-004**: V1 MUST support exactly one package per released review context at a time and MUST keep packaging anchored to that released review rather than to a broader workspace campaign or portfolio bundle.
- **FR-005**: The default-visible package summary MUST answer what review context is covered, what matters now, which findings and accepted risks are most material, which governance decisions require awareness, what evidence basis exists, and what the next stakeholder-facing action is.
- **FR-006**: The roadmap and candidate phrase `open decisions` MUST be narrowed in this slice to repo-real governance decision truth rooted in exceptions and risk acceptance. V1 MUST NOT imply a broader approval queue, decision inbox, or new workflow engine.
- **FR-007**: Accepted-risk and governance-decision content shown through this slice MUST summarize decision reason, accountable role or person when product truth exists, timing, and expiry, re-review, or follow-up state without exposing internal workflow residue. `accepted_risks` MUST contain currently valid tolerated-risk positions that explain why risk is presently accepted, while `governance_decisions` MUST contain only accepted-risk or exception entries that still require stakeholder awareness or follow-up. The same source decision MUST NOT appear in both lists for the same released review.
- **FR-008**: Evidence links in the package MUST point to existing entitled evidence or review-pack surfaces and MUST NOT duplicate raw payloads or provider-debug data as default package content. Existing report detail may only appear when an already-existing entitled viewer seam is available through the current evidence path.
- **FR-009**: The package MUST preserve artifact and result truth separation: findings, evidence snapshots, stored reports, review packs, and package summary remain distinct, and the package MUST NOT overwrite or redefine those source records as canonical truth.
- **FR-010**: The package MUST remain on-demand and bounded to existing released-review truth. V1 MUST NOT schedule, batch, queue campaigns, or automate multi-pack delivery.
- **FR-011**: When required source basis is unavailable, stale, expired, redacted, or incomplete, the surface MUST show explicit `partial`, `unavailable`, `expired`, or `blocked` states with truthful reason messaging rather than generating missing truth or implying false calmness.
- **FR-012**: Package availability MUST only be shown for entitled tenants and eligible released reviews in the current workspace.
- **FR-013**: Non-members and out-of-scope workspace, tenant, or review requests MUST resolve as deny-as-not-found, while actors inside an established scope MAY receive capability denial on the reused released-review detail route or on gated secondary artifact or proof paths when inherited capability checks deny access.
- **FR-014**: Every explicit package access or download and every secondary proof or review-pack action exposed through this slice MUST remain auditable through the current audit infrastructure.
- **FR-015**: V1 MUST NOT introduce custom branding, profile variants, or client-specific layout logic. `Download governance package` remains governance-package framing over the current export review pack for the released review and MUST NOT become a second downloadable artifact family.
- **FR-016**: Customer-facing and stakeholder-facing labels introduced by this slice MUST remain localization-ready for the existing DE and EN language posture.
- **FR-017**: The slice MUST expose no destructive, remediation, publication, regeneration, provider-changing, or admin-only actions in the management-ready package path.
- **FR-018**: The slice MUST NOT introduce a new global-searchable resource or widen existing cross-tenant discovery for package, review, report, or evidence artifacts.
- **FR-019**: Package summary and package-availability meaning for the same released review MUST stay consistent between workspace summary, released-review detail, and any package-access path.
- **FR-020**: The slice MUST reuse current review-pack, evidence, and stored-report artifacts instead of introducing a standalone governance-package report engine or package persistence family.
### Non-Functional Requirements
- **NFR-001**: V1 MUST introduce no new Graph calls, no new queue or `OperationRun`, and no scheduled or batched runtime path.
- **NFR-002**: Asset strategy remains unchanged. If later implementation unexpectedly registers assets, deployment still uses the existing `cd apps/platform && php artisan filament:assets` step.
- **NFR-003**: The package MUST remain understandable from the current review surfaces without requiring a second navigation shell or parallel package registry.
### UX Requirements
- **UXR-001**: The workspace list keeps one dominant `Open released review` action, and released review detail keeps one dominant package action.
- **UXR-002**: Raw or support detail remains secondary and explicit; the workspace list does not gain a peer package row action that competes with review inspection.
- **UXR-003**: Package `partial`, `unavailable`, `expired`, and `blocked` states, including stale or entitlement-restricted reasons, are calm, explicit, and management-readable.
### RBAC / Security Requirements
- **RBR-001**: The slice MUST reuse the canonical capability registry and MUST NOT introduce raw capability strings or role-name checks in feature code.
- **RBR-002**: Package availability, evidence links, report links, and review-pack access MUST NOT leak inaccessible tenant or review hints through counts, labels, empty states, or direct-link responses.
### Auditability / Observability Requirements
- **AOR-001**: Package access, package download, and secondary artifact access exposed by this slice MUST be auditable through the current audit infrastructure, with no new parallel audit store.
- **AOR-002**: Package output remains attributable to one released review context and one interpretation version so later operators can understand what meaning layer produced the package.
### Data / Truth-Source Requirements
- **DTR-001**: `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, `StoredReport`, `Finding`, `FindingException`, `FindingExceptionDecision`, and `AuditLog` remain the authoritative persisted inputs for this slice.
- **DTR-002**: Governance package output is a derived packaging layer only and MUST NOT become independent product truth about review state, evidence state, or governance state.
## Out of Scope
- new package persistence or campaign state
- scheduled or batched governance delivery
- PSA, CRM, or customer-communications automation
- AI-generated package content
- review or report generation, publication, or regeneration flows
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace | existing `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only | `recordUrl()` or full-row open to the released review | `Open released review` only | none | `Clear filters` only when filters are active; otherwise explanatory no-data text with no package-generation CTA | `N/A` | `N/A` | yes | Package readiness remains informational on the list surface so the dominant action stays choosing the released review context |
| Released Customer Review detail | existing tenant-scoped released review detail surface | `Download governance package` only when the released review and source basis are eligible | `N/A` | `N/A` | none | `N/A` | no additional peer header action beyond the dominant package action; evidence and existing review-pack links stay secondary | `N/A` | yes | Package access stays bound to the current released review and reuses the current export review pack, existing proof, entitlements, and audit gates |
Action Surface Contract is satisfied for this slice. Each affected surface keeps one dominant inspect or action model, no empty `ActionGroup` or `BulkActionGroup` placeholder, and no destructive-action placement rules are needed because destructive actions are out of scope. `UI-FIL-001` and `UX-001` are satisfied by staying inside native Filament read-only review surfaces, using explicit empty states, and keeping management-ready emphasis aligned to shared review and interpretation semantics rather than page-local visual language.
### Key Entities *(include if feature involves data)*
- **Released Review Context**: The existing tenant review chosen for stakeholder delivery and used to anchor all package content.
- **Governance Package Output**: The derived management-ready package rendered from existing review, evidence, report, and export truth for one released review. It is not a standalone persisted domain.
- **Customer-safe Interpretation Summary**: The shared interpretation output from Spec 259 that converts technical governance truth into management-ready meaning.
- **ReviewPack**: The existing packaged export artifact reused as a supporting source or linked artifact for stakeholder delivery.
- **EvidenceSnapshot**: The existing proof artifact referenced by package evidence basis and deeper drilldown.
- **StoredReport**: The existing report artifact family that may provide supporting source detail but is not the customer-safe package itself.
- **Governance Decision Record**: The existing exception or risk-acceptance decision truth, including `FindingExceptionDecision` and current governance validity.
- **AuditLog**: The existing audit trail that keeps package and supporting artifact access attributable without introducing a new audit store.
## Acceptance Criteria
- an authorized actor can access one repeatable on-demand governance package for an eligible released review from the current review surfaces
- the package is management-readable and customer-safe by default and clearly represents top findings, accepted risks, current governance decisions, and evidence links
- the package depends on the shared customer-safe interpretation layer and never bypasses it with raw provider or report language
- the `Download governance package` path remains governance-package framing over the current export review pack and does not introduce a second artifact family
- no new package domain, report engine, schedule, or cross-tenant discovery surface is introduced
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An entitled actor can reach the governance package for an eligible released review in three interactions or fewer from the current customer review workspace.
- **SC-002**: In 100% of validated scenarios, the same released review shows consistent executive summary meaning, top findings, accepted-risk governance decisions, and interpretation-dependent messaging across workspace summary, released-review detail, and package access.
- **SC-003**: In 100% of validated unauthorized or out-of-scope scenarios, the feature reveals no cross-tenant package, review, report, or evidence presence.
- **SC-004**: In 100% of validated incomplete-source scenarios, the package shows an explicit partial or unavailable state instead of implying a complete management package.
- **SC-005**: In 100% of validated package-download scenarios, `Download governance package` resolves to the current export review pack for the released review and preserves the ordering and wording of core governance truth and evidence links.
## Open Questions
- none

View File

@ -1,210 +0,0 @@
---
description: "Task list for Governance-as-a-Service Packaging v1"
---
# Tasks: Governance-as-a-Service Packaging v1
**Input**: Design documents from `specs/260-governance-service-packaging/`
**Prerequisites**: `specs/260-governance-service-packaging/plan.md` (required), `specs/260-governance-service-packaging/spec.md` (required), `specs/260-governance-service-packaging/research.md`, `specs/260-governance-service-packaging/data-model.md`, `specs/260-governance-service-packaging/quickstart.md`, `specs/260-governance-service-packaging/contracts/governance-service-packaging.openapi.yaml`
**Tests**: REQUIRED (Pest). Keep proof in the existing `Unit`, `Feature`, and one bounded `Browser` smoke family only; do not add a new heavy-governance, report-engine, or package-specific browser suite for this read-only packaging slice.
**Operations**: No new `OperationRun`, queue start, retry flow, report-generation run, scheduling, batching, or campaign orchestration is introduced. The package path must reuse existing released-review truth and current signed review-pack download behavior only.
**RBAC**: Workspace membership remains the first `404` boundary; tenant or review scope mismatches remain `404`; the reused released-review detail route and in-scope secondary artifact access may still return inherited `403` capability denials. Reuse `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`; do not introduce raw capability strings or role-name checks.
**Shared Pattern Reuse**: Reuse `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ReviewPackDownloadController`, `ArtifactTruthPresenter`, the current evidence-resource seams, localization files, and the shared audit path. No new `GovernancePackage` domain, report engine, stored-report viewer shell, AI summary layer, or parallel package workflow is allowed.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` remain globally disabled. No new panel, provider, destructive package action, or asset strategy change is allowed.
**Organization**: Tasks are grouped by user story so package entry, management-ready meaning, and audit or isolation behavior remain independently testable while the implementation stays bounded to one on-demand package for one released review context.
## Test Governance Checklist
- [x] Lane assignment stays `confidence` plus the existing bounded `browser` smoke and remains the narrowest sufficient proof for this slice.
- [x] New or changed tests stay in existing `apps/platform/tests/Unit/TenantReview/`, `apps/platform/tests/Feature/Reviews/`, `apps/platform/tests/Feature/TenantReview/`, `apps/platform/tests/Feature/ReviewPack/`, `apps/platform/tests/Feature/Evidence/`, and `apps/platform/tests/Browser/Reviews/` families only.
- [x] Shared helpers, fixtures, membership setup, released-review fixtures, review-pack fixtures, evidence snapshots, and decision fixtures stay cheap by default; no queue or provider-sync setup is added.
- [x] Planned validation commands cover package derivation, package access, auditability, and rendered disclosure without widening into unrelated lanes.
- [x] The declared surface test profile stays `shared-detail-family` for the released-review detail and `standard-native-filament` for the workspace page.
- [x] Browser work stays bounded to the existing `apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` family only.
- [x] Any drift toward new package persistence, report engines, scheduling, campaigns, stored-report viewer productization, or broader governance-inbox semantics resolves as `reject-or-split` or `follow-up-spec`, not hidden implementation growth.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the current review, package, interpretation, localization, audit, and authorization seams before implementation begins.
- [x] T001 Review `specs/260-governance-service-packaging/spec.md`, `specs/260-governance-service-packaging/plan.md`, `specs/260-governance-service-packaging/research.md`, `specs/260-governance-service-packaging/data-model.md`, `specs/260-governance-service-packaging/quickstart.md`, `specs/260-governance-service-packaging/contracts/governance-service-packaging.openapi.yaml`, `specs/258-customer-review-productization/spec.md`, and `specs/259-compliance-evidence-mapping/spec.md` together so the implementation stays anchored to released-review truth and shared interpretation.
- [x] T002 [P] Confirm the workspace and released-review seams in `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`.
- [x] T003 [P] Confirm the existing package-delivery and artifact-truth seams in `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`.
- [x] T004 [P] Confirm the localization, audit, and capability seams in `apps/platform/lang/en/localization.php`, `apps/platform/lang/de/localization.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Support/Auth/Capabilities.php`, and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock the shared package derivation, signed-download reuse, copy, and drift-stop rules that every user story depends on.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T005 [P] Extend `apps/platform/tests/Unit/TenantReview/TenantReviewComposerTest.php` and `apps/platform/tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php` to lock package derivation to existing review summary and section truth, without a new `GovernancePackage` model, persistence table, or report namespace.
- [x] T006 [P] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to prove the customer-workspace package path reuses `downloadCurrentReviewPackAction()` and the current signed review-pack download behavior rather than export generation, `ReviewPackService::generateFromReview()`, or any `OperationRun` start path.
- [x] T007 Implement the derived package-summary and package-availability seam inside `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` without introducing a new packaging service layer, persisted lifecycle, or broader decision framework.
- [x] T008 [P] Add shared management-ready vocabulary and calm unavailable-state copy in `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php`, keeping the roadmap phrase `open decisions` narrowed to accepted-risk and exception-decision truth only.
- [x] T009 [P] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` to block drift into a competing workspace package action, operator-only export actions in customer-workspace mode, or a new stored-report viewer shell from the package path.
**Checkpoint**: Package derivation, signed-download reuse, localized vocabulary, and the main drift-stop guards are in place before surface-specific work starts.
---
## Phase 3: User Story 1 - Produce One Repeatable Stakeholder Package From A Released Review (Priority: P1)
**Goal**: Give an entitled actor one on-demand package entry for one released review context without starting a new reporting or generation workflow.
**Independent Test**: Open an eligible released review from the current workspace, confirm the workspace shows package readiness as information only, and confirm the released-review detail reuses the current signed review-pack delivery seam without triggering generation.
### Tests for User Story 1
- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` to cover package readiness, management teaser copy, evidence-basis disclosure, calm `partial`, `unavailable`, `expired`, and `blocked` states, and the absence of a competing package row action.
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` to cover released-review launch, package-ready signaling, explicit unavailable-state reasons, governance-package framing over the current export review pack, and signed review-pack reuse from the workspace flow.
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php` to assert one dominant `Download governance package` action in customer-workspace mode, explicit framing over the current export review pack, and no export-generation fallback when source basis is missing.
### Implementation for User Story 1
- [x] T013 [US1] Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` to show package readiness, management teaser, evidence basis, and calm unavailable messaging while keeping `Open released review` as the only dominant action.
- [x] T014 [US1] Update `apps/platform/app/Filament/Resources/TenantReviewResource.php` and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` so the released-review detail owns the package context in `customer_workspace=1` mode and reuses `downloadCurrentReviewPackAction()` instead of `exportExecutivePackAction()` or any generation path.
- [x] T015 [US1] Update `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` and `apps/platform/app/Services/ReviewPackService.php` only as needed to carry source-surface, review, tenant-filter, and interpretation metadata through the existing signed download seam without creating a new artifact namespace or package-generation entry point.
Repo truth: the existing signed download seam already carried `source_surface`, `review_id`, `tenant_filter_id`, and `interpretation_version`, so no controller or service code change was required.
**Checkpoint**: User Story 1 is independently functional when one released review context exposes a repeatable package path that reuses current review-pack truth instead of starting export generation.
---
## Phase 4: User Story 2 - Read Management-Ready Governance Meaning Without Raw Operator Translation (Priority: P1)
**Goal**: Keep the package management-readable and customer-safe by deriving its meaning from shared interpretation and existing review sections only.
**Independent Test**: Open one released-review package context and verify that executive summary, top findings, accepted risks, governance decisions, evidence basis, and next action all come from the shared interpretation layer and current review truth, with raw/support detail hidden by default.
### Tests for User Story 2
- [x] T016 [P] [US2] Extend `apps/platform/tests/Unit/TenantReview/TenantReviewComposerTest.php` to cover management-ready executive summary, top findings, evidence-basis summary, supporting artifact links, and explicit partial states when interpretation truth is incomplete.
- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php` to cover customer-safe management summary content, hidden raw or support detail by default, and package-summary consistency between released-review detail and supporting artifacts.
- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php` and `apps/platform/tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php` to keep accepted-risk and exception follow-up language narrowed to current governance-decision truth, assert `accepted_risks` and `governance_decisions` stay disjoint, and prevent drift into a broader decision inbox or workflow board.
### Implementation for User Story 2
- [x] T019 [US2] Update `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php` and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` to derive package summary fields from existing `control_interpretation`, `highlights`, `accepted_risks`, and related review section payloads without a new package-local mapper, report engine, or AI summary layer, while keeping stable accepted-risk entries separate from governance-decision follow-up entries.
- [x] T020 [US2] Update `apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php` and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` to keep calm disclosure, package availability messaging, and supporting evidence or review-pack links aligned with shared interpretation and artifact truth, using the canonical mapping `publishable -> available`, `internal_only/stale -> partial`, `blocked/missing_input -> unavailable`, `historical_only -> expired`, and package-level entitlement restrictions -> `blocked`, while keeping secondary-proof restrictions on supporting-link disclosure instead of blocking the package summary.
- [x] T021 [US2] Finalize localized DE and EN management-ready copy in `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php` for executive summary labels, evidence-basis messaging, accepted-risk follow-up, governance-decision wording, and explicit `partial`, `unavailable`, `expired`, and `blocked` states with truthful stale or entitlement-restricted reasons.
**Checkpoint**: User Story 2 is independently functional when the same released review shows consistent customer-safe package meaning across workspace summary, released-review detail, and package access without raw operator translation.
---
## Phase 5: User Story 3 - Trust Packaging Boundaries, Auditability, And Scope Isolation (Priority: P2)
**Goal**: Keep package delivery attributable, tenant-safe, and bounded even when secondary proof access is limited.
**Independent Test**: Access or download the package for an entitled review, verify current audit coverage, verify explicit secondary-artifact unavailability where needed, and confirm out-of-scope tenant or review targets resolve without leakage.
### Tests for User Story 3
- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to cover deny-as-not-found package readiness, tenant-prefilter isolation, and no review, package, report, or evidence hint leakage across tenants.
- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, and `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` so the reused released-review detail route can assert inherited in-scope `403` capability denial, while secondary review-pack and evidence links stay capability-gated and the management summary remains readable when only supporting access is restricted.
- [x] T024 [P] [US3] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` to assert package access, signed download, and proof opens stay on the current audit infrastructure with source-surface and interpretation-version metadata.
### Implementation for User Story 3
- [x] T025 [US3] Update `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` to reuse current audit events and metadata instead of creating a new package audit family.
Repo truth: the existing audit action IDs and logger already covered workspace open, tenant-review open, signed pack download, and evidence open with the required metadata, so no new audit family was added.
- [x] T026 [US3] Keep neutral governance-package framing and secondary artifact disclosure inside `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and existing evidence or review-pack link surfaces so `Download governance package` always maps to the current export review pack and no branding or profile-variant system is introduced in v1.
- [x] T027 [US3] Handle missing, expired, redacted, or unavailable supporting artifacts in `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` so the slice stays explicit about unavailable proof and does not add a stored-report viewer shell, AI summary fallback, schedule, batch, campaign, or broader governance-inbox semantics.
**Checkpoint**: User Story 3 is independently functional when package access remains attributable, bounded, and tenant-safe without widening the slice into new workflows or surfaces.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Finish focused validation, formatting, and explicit drift checks without widening scope.
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantReview/TenantReviewComposerTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php`.
- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/TenantReview/TenantReviewExecutivePackTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackValidRiskAcceptanceTest.php tests/Feature/Evidence/ExceptionValidityEvidenceIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`.
- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` and keep browser work bounded to this existing smoke family only.
- [x] T031 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for all touched platform files.
- [x] T032 [P] Review touched code in `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` to confirm the package path reuses current review-pack truth and signed download behavior rather than new persistence, export generation, a report engine, an `OperationRun`, or schedule, batch, or campaign workflows.
- [x] T033 [P] Review touched code in `apps/platform/bootstrap/providers.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, touched Blade views, and touched localization files to confirm no new panel/provider, global-search surface, asset strategy, stored-report viewer shell, AI summaries, or broader governance-inbox semantics were introduced.
---
## 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 one released-review package entry path.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the package entry path and its management-ready meaning stay aligned.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because audit and isolation checks rely on the package surfaces already existing.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and delivers the core on-demand package path for one released review.
- **US2 (P1)**: independently testable after Phase 2 and should land with US1 so the package does not expose entry without trustworthy management meaning.
- **US3 (P2)**: independently testable after Phase 2 and hardens auditability, secondary-access handling, and scope isolation after the core path exists.
### Within Each User Story
- Write the listed Pest coverage first for each story and make it fail for the intended gap.
- Keep implementation inside the existing review, review-pack, interpretation, evidence, localization, and audit seams named in the plan.
- Re-run the narrowest relevant validation command after each story checkpoint before moving to the next story.
---
## Parallel Execution Examples
### Setup / Foundations
- T002, T003, and T004 can run in parallel during seam confirmation.
- T005, T006, T008, and T009 can run in parallel before the shared package seam in T007 is finalized.
### User Story 1
- T010, T011, and T012 can run in parallel before runtime edits begin.
- After the package-entry contract settles, T013 and T015 can proceed in parallel while T014 finalizes the released-review detail surface.
### User Story 2
- T016, T017, and T018 can run in parallel because they cover different aspects of the meaning contract.
- T019 and T020 can proceed together once the failing tests prove the gap; T021 can land in parallel with the final copy pass.
### User Story 3
- T022, T023, and T024 can run in parallel because they cover authorization, secondary-access, and audit behavior separately.
- T025 and T026 can proceed together once the package path is stable; T027 should follow to finish the explicit unavailable-state handling.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. The feature is only product-meaningful when the actor can both reach the package from one released review and trust the management-ready meaning shown there.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and US2 together as the smallest sellable packaging slice.
3. Add US3 to harden auditability, secondary-access boundaries, and explicit unavailable behavior.
4. Finish with the focused validation and drift-review tasks in Phase 6.
### Team Strategy
1. Settle the shared package-derivation and signed-download guardrails first.
2. Parallelize focused tests inside each story before widening implementation.
3. Serialize merges around `CustomerReviewWorkspace`, `ViewTenantReview`, and the shared localization files so the package vocabulary and action hierarchy stay coherent.
---
## Deferred Follow-Ups / Non-Goals
- Multi-review or multi-tenant package automation stays deferred.
- Stored-report viewer productization stays deferred unless a repo-real existing viewer seam proves insufficient.
- Schedule, batch, campaign, PSA, CRM, and customer-communications workflows stay deferred.
- Package profile variants or broader branding systems stay deferred beyond neutral v1 governance-package framing over the current export review pack.

View File

@ -1,50 +0,0 @@
# Specification Quality Checklist: Provider-Missing Policy Visibility & Restore Continuity v1
**Purpose**: Validate the spec package before implementation planning and task execution
**Created**: 2026-05-01
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The spec stays on one bounded policy-only provider-presence correction and does not drift into workspace/tenant lifecycle governance
- [x] Mandatory repo sections are completed, including candidate check, scope fields, cross-cutting reuse, provider boundary, guardrails, proportionality, and testing impact
- [x] Candidate selection is grounded in repo reality as well as product docs, including the already-prepared Specs 251-260 and the documented narrower lifecycle follow-up
- [x] No implementation diff leaks into the feature contract beyond concrete repo surfaces needed for local fit
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Functional requirements are testable and bounded
- [x] Success criteria are measurable and behavior-focused
- [x] Acceptance scenarios cover current-state policy truth, backup eligibility, restore continuity, and reappearance behavior
- [x] Edge cases cover ignored-plus-missing overlap, supported-type reclassification, and historical backup continuity
- [x] Dependencies, assumptions, and risks are explicit
## Guardrail & Surface Fit
- [x] The spec, plan, and package keep the same native Filament classification, shared-family relevance, and surface-role hierarchy for policy, backup, and restore screens
- [x] `ignored_at` is explicitly reserved for local suppression and `missing_from_provider_at` is the only new provider-presence truth proposed
- [x] Combined-state filter membership and blocked-reason precedence are explicit across the spec, data model, plan, and conceptual contract
- [x] Decision-first obligations are explicit for changed surfaces: one dominant next action, diagnostics-secondary ordering, hidden/capability-gated support detail, and no duplicate visible decision summary
- [x] No new page, panel, provider, asset strategy, lifecycle engine, or SoftDeletes path is introduced
- [x] OperationRun impact is explicitly bounded to existing sync/backup start surfaces with no new run type
- [x] Provider-specific semantics stay inside sync interpretation rather than leaking into platform-core operator vocabulary
## Test Governance
- [x] Planned validation stays in focused `fast-feedback` and `confidence` lanes only
- [x] The package reuses existing policy, backup, restore, and badge test families instead of introducing browser or heavy-governance proof
- [x] The US2 proving suite is aligned across spec, plan, quickstart, and tasks
- [x] Reviewer handoff and narrow Sail commands are explicit in the plan and quickstart artifacts
## Notes
- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `.specify/memory/constitution.md`, `apps/platform/app/Models/Policy.php`, `apps/platform/app/Services/Intune/PolicySyncService.php`, `apps/platform/app/Services/Intune/BackupService.php`, `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/RestoreRunResource.php`, and the related sync/backup/restore tests on 2026-05-01.
- No application implementation was performed while preparing this spec package.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Outcome**: `keep`
- **Reason**: The package intentionally pulls one documented narrow follow-up ahead of the full lifecycle taxonomy, but it stays policy-only, uses one aligned proving suite, makes the combined-state contract explicit, reuses existing shared seams, and fixes a repo-visible truth bug without importing the broader lifecycle framework.
- **Workflow result**: Ready for implementation planning and task execution

View File

@ -1,300 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Provider-Missing Policy Visibility & Restore Continuity v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the Provider-Missing Policy Visibility & Restore
Continuity v1 planning package.
These paths describe existing Filament admin and tenant-scoped routes reused by
implementation. The schemas document the derived policy-presence, current
backup-eligibility, and historical restore-continuity contract expected over
existing policy, backup, and restore truth; they do not define a new public
REST API.
servers:
- url: /
paths:
/admin/t/{tenant}/policies:
get:
summary: View tenant policies with distinct local-ignore and provider-missing states
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: query
name: visibility
required: false
description: |
`ignored` includes both `ignored_locally` and
`ignored_locally_provider_missing`. `provider_missing` includes both
`provider_missing` and `ignored_locally_provider_missing`.
schema:
type: string
enum:
- active
- ignored
- provider_missing
- all
responses:
'200':
description: Policy list rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/PolicyIndexPageModel'
'403':
description: Forbidden for an in-scope actor missing the existing policy capability
'404':
description: Not found for non-members, tenant mismatches, or out-of-scope targets
/admin/t/{tenant}/policies/{policy}:
get:
summary: View one tenant policy with provider-presence continuity context
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: policy
required: true
schema:
type: integer
responses:
'200':
description: Policy detail rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/PolicyDetailModel'
'403':
description: Forbidden for an in-scope actor missing the existing policy capability
'404':
description: Not found for non-members, tenant mismatches, or inaccessible policy targets
/admin/t/{tenant}/backup-sets/create:
get:
summary: Open current backup selection with provider-missing eligibility rules
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
responses:
'200':
description: Backup-set creation surface rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/BackupSelectionModel'
'403':
description: Forbidden for an in-scope actor missing the existing backup capability
'404':
description: Not found for non-members, tenant mismatches, or out-of-scope targets
/admin/t/{tenant}/restore-runs/create:
get:
summary: Open restore item selection with historical continuity for provider-missing policies
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: query
name: backup_set_id
required: false
schema:
type: integer
responses:
'200':
description: Restore-run creation surface rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/RestoreSelectionModel'
'403':
description: Forbidden for an in-scope actor missing the existing restore capability
'404':
description: Not found for non-members, tenant mismatches, or inaccessible backup targets
components:
schemas:
PolicyVisibilityState:
type: string
description: |
Combined state remains explicit as `ignored_locally_provider_missing`.
List filters continue to expose it through both the `ignored` and
`provider_missing` filter tokens.
enum:
- active
- ignored_locally
- provider_missing
- ignored_locally_provider_missing
PolicyActionEligibility:
type: object
required:
- can_sync
- can_export_current
- can_restore_history
properties:
can_sync:
type: boolean
can_export_current:
type: boolean
can_restore_history:
type: boolean
blocked_reason:
type: string
nullable: true
description: |
Primary current-state action blocker. When both `ignored_at` and
`missing_from_provider_at` are present, this resolves to
`provider_missing` because fresh provider-backed capture is not
possible; local ignore remains secondary context.
enum:
- ignored_locally
- provider_missing
PolicyListRow:
type: object
required:
- id
- display_name
- policy_type
- visibility_state
- action_eligibility
properties:
id:
type: integer
display_name:
type: string
policy_type:
type: string
visibility_state:
$ref: '#/components/schemas/PolicyVisibilityState'
ignored_at:
type: string
format: date-time
nullable: true
missing_from_provider_at:
type: string
format: date-time
nullable: true
last_synced_at:
type: string
format: date-time
nullable: true
action_eligibility:
$ref: '#/components/schemas/PolicyActionEligibility'
PolicyIndexPageModel:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/PolicyListRow'
PolicyDetailModel:
type: object
required:
- policy
- historical_restore_available
properties:
policy:
$ref: '#/components/schemas/PolicyListRow'
historical_restore_available:
type: boolean
continuity_message:
type: string
nullable: true
BackupSelectionRow:
type: object
required:
- policy_id
- label
- eligible
properties:
policy_id:
type: integer
label:
type: string
eligible:
type: boolean
blocked_reason:
type: string
nullable: true
description: |
Primary current backup blocker. When a policy is both locally ignored
and provider-missing, this remains `provider_missing` because current
capture cannot proceed.
enum:
- ignored_locally
- provider_missing
secondary_local_ignore:
type: boolean
description: |
True when the row is also locally ignored. This remains secondary
context when `blocked_reason` resolves to `provider_missing`.
historical_continuity_available:
type: boolean
BackupSelectionModel:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/BackupSelectionRow'
RestoreSelectionRow:
type: object
required:
- backup_item_id
- label
- selectable
properties:
backup_item_id:
type: integer
label:
type: string
selectable:
type: boolean
provider_missing_notice:
type: boolean
continuity_message:
type: string
nullable: true
RestoreSelectionModel:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/RestoreSelectionRow'

View File

@ -1,112 +0,0 @@
# Data Model: Provider-Missing Policy Visibility & Restore Continuity v1
## Overview
This slice changes one existing persisted entity and introduces only derived projections elsewhere. No new table, registry, or artifact family is planned.
## Entity: Policy (existing, modified)
**Table**: `policies`
### Fields
| Field | Type | Source | Notes |
|---|---|---|---|
| `id` | bigint | existing | Primary key |
| `workspace_id` | bigint | existing | Required ownership anchor |
| `tenant_id` | bigint | existing | Required ownership anchor |
| `external_id` | string | existing | Provider-facing stable key |
| `policy_type` | string | existing | Canonical local policy type |
| `ignored_at` | timestamp nullable | existing | Explicit local suppression only after this slice |
| `missing_from_provider_at` | timestamp nullable | new | Provider-missing observation in the current supported provider-backed result set |
| `last_synced_at` | timestamp nullable | existing | Last successful provider observation/update on this row |
### Invariants
- `workspace_id` and `tenant_id` remain required and non-null.
- `ignored_at` may only be set or cleared by explicit local suppression flows.
- `missing_from_provider_at` may only be set or cleared by sync/provider-observation logic.
- A policy row remains persisted and viewable even when `missing_from_provider_at` is set.
### Derived Visibility States
| Derived state | `ignored_at` | `missing_from_provider_at` | Meaning |
|---|---|---|---|
| `active` | null | null | Current policy is locally visible and currently observed in the provider-backed result set |
| `ignored_locally` | set | null | Current policy is intentionally hidden/suppressed locally |
| `provider_missing` | null | set | Current policy remains local truth but is not currently observed in the supported provider-backed result set |
| `ignored_locally_provider_missing` | set | set | Policy is locally suppressed and also currently missing from the provider-backed result set |
### Filter Membership And Precedence
- `active` filter returns only `active` rows.
- `ignored` filter returns `ignored_locally` and `ignored_locally_provider_missing` rows.
- `provider_missing` filter returns `provider_missing` and `ignored_locally_provider_missing` rows.
- `all` returns the complete set.
- For current backup/export, `provider_missing` wins as the primary `blocked_reason` when both timestamps are present because fresh provider-backed capture is impossible even if the row is also locally ignored.
### State Transition Rules
| Event | From | To | Notes |
|---|---|---|---|
| Local ignore | `active` or `provider_missing` | `ignored_locally` or `ignored_locally_provider_missing` | Explicit operator intent |
| Local restore/unignore | `ignored_locally` or `ignored_locally_provider_missing` | `active` or `provider_missing` | Clears only `ignored_at` |
| Sync marks missing | `active` or `ignored_locally` | `provider_missing` or `ignored_locally_provider_missing` | Sets only `missing_from_provider_at` |
| Sync reappears | `provider_missing` or `ignored_locally_provider_missing` | `active` or `ignored_locally` | Clears only `missing_from_provider_at` |
| Sync reclassifies to supported type | any provider-present state | provider-present state | Updates `policy_type` without using ignore semantics |
## Derived Projection: Current Backup Eligibility
**Persistence**: none; computed from `Policy`
| Field | Type | Meaning |
|---|---|---|
| `policy_id` | bigint | Policy being evaluated for current backup/export |
| `eligible` | boolean | True only when the policy is neither locally ignored nor provider-missing for current-state capture |
| `blocked_reason` | string nullable | `ignored_locally` or `provider_missing` when not eligible; `provider_missing` wins when both timestamps are present |
| `historical_continuity_available` | boolean | Indicates whether backup history already exists even if current capture is blocked |
### Rules
- Fresh provider-backed capture requires `ignored_at = null` and `missing_from_provider_at = null`.
- When both timestamps are set, current backup/export MUST return `blocked_reason = provider_missing` and MUST retain local ignore as secondary UI context.
- Historical backup existence does not make a provider-missing policy eligible for fresh capture.
## Derived Projection: Restore Continuity
**Persistence**: none; computed from `BackupItem` plus optional linked `Policy`
| Field | Type | Meaning |
|---|---|---|
| `backup_item_id` | bigint | Historical item being offered for restore |
| `policy_id` | bigint nullable | Linked current policy row when present |
| `selectable` | boolean | Restore eligibility of the historical item |
| `provider_missing_notice` | boolean | Shows that the current live policy is missing while the historical item remains valid |
| `continuity_message` | string nullable | Calm explanation shown in restore selection |
### Rules
- Historical restore selection continues to follow `BackupItem` truth.
- Provider-missing status on the live policy is descriptive unless an independent restore rule blocks the historical item.
## Audit Payload (existing infrastructure, new event meanings)
**Persistence**: existing `audit_logs`
| Field | Type | Meaning |
|---|---|---|
| `action_id` | string | `policy.provider_missing_detected` or `policy.provider_missing_cleared` or equivalent existing action ids if reused |
| `workspace_id` | bigint | Existing scope anchor |
| `tenant_id` | bigint | Existing scope anchor |
| `subject_type` | string | `policy` |
| `subject_id` | bigint | Policy id |
| `metadata.external_id` | string | Provider-facing stable key |
| `metadata.policy_type` | string | Canonical local policy type |
| `metadata.transition_at` | timestamp | When the presence transition was observed |
## Out of Scope Data Shapes
- No `provider_deleted_at`
- No lifecycle history table
- No materialized `policy_state` enum column
- No new recovery artifact or backup continuity table

View File

@ -1,252 +0,0 @@
# Implementation Plan: Provider-Missing Policy Visibility & Restore Continuity v1
**Branch**: `261-provider-missing-policy-visibility` | **Date**: 2026-05-01 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
## Summary
Prepare one bounded policy-truth correction that separates provider-missing presence from intentional local suppression without opening the broader workspace or tenant lifecycle program. The narrow implementation path is to add `missing_from_provider_at` on `policies`, reserve `ignored_at` for explicit user suppression only, stop sync and type-filter logic from writing `ignored_at` for provider absence, keep missing policies visible as historical local truth, block current-state backup/export actions for missing policies, and preserve restore continuity for historical `BackupItem` records.
Repo truth already exposes the exact seams this slice needs: [../../apps/platform/app/Models/Policy.php](../../apps/platform/app/Models/Policy.php) has `ignored_at` but no provider-presence field; [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) currently clears `ignored_at` on `updateOrCreate()` and marks reclassified or filtered records ignored; [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) only distinguishes active versus ignored; [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) selects backup candidates with `whereNull('ignored_at')`; [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) filters restore-option continuity through the same ignored check; and current tests such as [../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php](../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php) codify the conflated behavior that this spec intentionally corrects.
V1 therefore stays narrow: no new panel or provider, no global-search change, no new asset registration, no `SoftDeletes`, no lifecycle engine, no provider-deleted distinction, no purge flow, no multi-object rollout, and no cross-tenant workflow. The work is a policy-only truth correction that reuses the existing policy, backup, restore, audit, and sync seams.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `PolicySyncService`, `BackupService`, `PolicyResource`, `RestoreRunResource`, policy bulk jobs, audit infrastructure, and policy badge helpers
**Storage**: PostgreSQL via existing `policies`, `policy_versions`, `backup_sets`, `backup_items`, `restore_runs`, `operation_runs`, and `audit_logs`; one nullable timestamp is planned on `policies`
**Testing**: Pest v4 feature and focused unit coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, existing admin and tenant-scoped Filament surfaces only
**Project Type**: Web application (Laravel monolith with Filament resources and pages)
**Performance Goals**: keep provider-missing detection inside existing sync result processing, avoid extra Graph calls, keep policy list and restore option queries eager-load safe, and avoid widening queue or asset cost
**Constraints**: no `SoftDeletes`, no `provider_deleted_at`, no new lifecycle registry/service, no panel/provider changes, no asset strategy change, no new globally searchable resource, and no customer-facing portal work
**Scale/Scope**: 1 model/migration, 1 sync service cluster, 1 policy resource, 1 backup service seam, 1 restore selection seam, bounded audit updates, and targeted policy/backup/restore tests
## Likely Affected Repo Surfaces
- [../../apps/platform/app/Models/Policy.php](../../apps/platform/app/Models/Policy.php) for `missing_from_provider_at`, casts, scopes, and derived provider-presence helpers.
- [../../apps/platform/database/migrations](../../apps/platform/database/migrations) for one migration adding `missing_from_provider_at` to `policies`.
- [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) for provider-missing detection, reappearance clearing, subtype filtering, and reclassification semantics.
- [../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php](../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php) and existing unignore flows as the retained local-suppression path that must stay on `ignored_at` only.
- [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) plus its `Pages/ListPolicies.php` and `Pages/ViewPolicy.php` surfaces for badges, filters, helper copy, and action availability.
- [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) and current backup/export actions that choose policies from current local truth.
- [../../apps/platform/app/Filament/Resources/BackupSetResource.php](../../apps/platform/app/Filament/Resources/BackupSetResource.php) or related picker helpers if the current backup-set policy picker needs provider-missing reason text.
- [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) for restore item option continuity and provider-missing messaging.
- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) and the existing audit logger path for provider-missing and reappeared events if no existing audit action is sufficient.
- [../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php](../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php), [../../apps/platform/tests/Feature/PolicySyncServiceTest.php](../../apps/platform/tests/Feature/PolicySyncServiceTest.php), [../../apps/platform/tests/Feature/BulkDeletePoliciesTest.php](../../apps/platform/tests/Feature/BulkDeletePoliciesTest.php), [../../apps/platform/tests/Feature/BulkUnignorePoliciesTest.php](../../apps/platform/tests/Feature/BulkUnignorePoliciesTest.php), [../../apps/platform/tests/Feature/BulkExportToBackupTest.php](../../apps/platform/tests/Feature/BulkExportToBackupTest.php), [../../apps/platform/tests/Feature/Filament/BackupCreationTest.php](../../apps/platform/tests/Feature/Filament/BackupCreationTest.php), [../../apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php](../../apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php), [../../apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php](../../apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php), [../../apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php](../../apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php), and [../../apps/platform/tests/Unit/Badges/PolicyBadgesTest.php](../../apps/platform/tests/Unit/Badges/PolicyBadgesTest.php) for bounded regression proof.
## Policy Presence / Sync Fit
- Treat provider presence as a property of the local policy row, not a second entity or lifecycle engine. The current narrow truth is `ignored_at` plus `missing_from_provider_at`.
- The derived state family stays bounded:
- active: `ignored_at = null` and `missing_from_provider_at = null`
- ignored locally: `ignored_at != null` and `missing_from_provider_at = null`
- provider missing: `ignored_at = null` and `missing_from_provider_at != null`
- ignored locally + provider missing: both timestamps present, with local suppression as the primary local-control meaning
- Policy list filtering stays deterministic: the combined state belongs to both the `ignored` and `provider_missing` filter views so operators can reach it from either workflow.
- [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) must stop blindly resetting `ignored_at` on `updateOrCreate()`.
- Any sync path that currently uses `ignored_at` for subtype filtering or reclassification must instead either reclassify the row to a supported canonical type or mark `missing_from_provider_at` when the object falls out of the supported provider-backed result set.
- Reappearance must clear `missing_from_provider_at` without automatically clearing `ignored_at`.
- Existing local delete/restore semantics in `ignore()` and `unignore()` remain unchanged and stay rooted in `ignored_at`.
## Backup / Restore Continuity Fit
- [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) and any current backup/export picker that selects policies for fresh capture must keep provider-missing policies visible but blocked from current-state capture.
- When both `ignored_at` and `missing_from_provider_at` are present, current backup/export uses `provider_missing` as the primary blocked reason because the product cannot truthfully claim fresh provider-backed capture is possible.
- Existing historical `BackupItem` truth remains authoritative for restore continuity. The system should not require a live provider-present policy row to keep a historical restore item selectable.
- [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) should keep continuity messaging secondary and truthful: the backup item is historical and selectable, while the live policy is currently provider-missing.
- Current-state backup blocking should prefer calm explanatory copy and zero new run creation. Historical restore remains a separate allowed path.
## UI / Filament & Livewire Fit
- Filament remains v5 on Livewire v4. No new panel or provider is planned, and provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php).
- [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) already exposes a list and a view page and explicitly sets `$isGloballySearchable = false`; this feature does not change global-search posture.
- Keep the existing policy resource as the primary current-state decision surface. Do not add a new ghost-policy page, special diagnostics page, or alternate provider-missing registry.
- Any destructive action that survives this slice remains the existing local ignore/restore family and must continue to use confirmation and server-side authorization. This feature does not introduce a new destructive action.
- Asset strategy remains unchanged. No new Filament assets or deployment `filament:assets` steps are expected beyond the existing platform process.
- If status badges or helper copy expand, reuse the existing badge and policy presentation seam instead of adding local ad-hoc mappings.
## RBAC / Authorization Fit
- Workspace membership and tenant entitlement remain the first boundary. Nothing in provider-missing semantics changes `404` versus `403` scope handling.
- Existing policy, backup, and restore capabilities remain authoritative. No new capability string or role check is justified.
- Backup blocking for provider-missing policies is business-state gating, not authorization. In-scope operators still see the policy and the historical restore path; out-of-scope actors still receive deny-as-not-found.
- Any reused detail or action route must keep current server-side authorization checks and confirmation behavior.
## Audit / Logging Fit
- Reuse the existing audit infrastructure; do not create a second lifecycle-log family.
- The narrow expectation is one explicit audit event when a previously observed policy becomes provider-missing and one when it later reappears.
- Audit payload should stay tenant-safe and minimal: policy id, policy external id, canonical policy type, transition timestamp, and sync source context are sufficient.
- No new `OperationRun` type is planned. Current sync actions may continue to use the existing run UX only when a sync actually starts.
## Data & Query Fit
- Add one nullable timestamp column: `policies.missing_from_provider_at`.
- Keep `workspace_id` and `tenant_id` as required anchors on policy truth; this slice does not weaken tenant ownership.
- Prefer deriving provider-presence state via model helpers or narrow query scopes instead of storing a second status enum.
- Update current policy-eligibility queries to use both `ignored_at` and `missing_from_provider_at` where current-state capture must stay live-only.
- Combined-state filter membership is derived, not stored: ignored views include `ignored_locally_provider_missing`, provider-missing views include `ignored_locally_provider_missing`, and current backup/export blocked-reason precedence resolves to `provider_missing` when both timestamps are present.
- Keep restore queries historical-first: if a `BackupItem` remains eligible, provider-missing context is descriptive rather than disqualifying unless a separate restore rule already blocks it.
- Avoid adding speculative `provider_state_reason` or lifecycle-history tables in v1.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: status messaging, badges, row/detail actions, backup eligibility messaging, restore continuity descriptions, and audit labels
- **State layers in scope**: page, detail, action modal, picker description, query/model state
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first current policy meaning, diagnostics-second sync/history context, raw/support third and only where existing surfaces already expose it
- **Raw/support gating plan**: no new raw payload disclosure; any provider detail remains secondary on existing policy/version evidence surfaces only
- **One-primary-action / duplicate-truth control**: policy list/detail own current-state meaning; backup flows own current capture eligibility; restore flows own historical continuity; no surface should restate another surface's primary truth as an equal-priority summary
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory because this slice changes a shared state vocabulary across policy, backup, and restore seams
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned; any attempt to add a new provider-missing page, new lifecycle registry, or new panel becomes exception-required drift
- **Active feature PR close-out entry**: Guardrail / State Vocabulary
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `Policy`, `PolicySyncService`, `PolicyResource`, `BackupService`, `BackupSetResource` picker helpers if needed, `RestoreRunResource`, audit action ids, and the policy badge/presentation seam
- **Shared abstractions reused**: existing policy model truth, shared policy filters/actions, existing backup selection path, existing restore-item option builders, existing audit logger, and existing badge rendering tests
- **New abstraction introduced? why?**: none planned. If implementation needs a tiny provider-presence helper, keep it on `Policy` or an existing policy-presenter seam instead of creating a new lifecycle framework.
- **Why the existing abstraction was sufficient or insufficient**: current shared seams already own the right surfaces. They are simply fed by the wrong state vocabulary today.
- **Bounded deviation / spread control**: none planned. Any proposal for a separate ghost-policy registry, dedicated diagnostics page, or generic managed-object lifecycle layer should be deferred to the broader lifecycle taxonomy follow-up.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, by narrowing when current backup/export actions may start and by leaving sync-retry behavior on the existing shared path
- **Central contract reused**: existing policy sync and backup/export start UX
- **Delegated UX behaviors**: allowed sync actions keep the current queued toast/link behavior; blocked provider-missing current backup/export actions stop before run creation and explain why locally
- **Surface-owned behavior kept local**: policy, backup, and restore surfaces own provider-missing messaging only; they do not create a new run type or monitoring surface
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: Microsoft Graph list results, subtype inclusion/exclusion, and canonical-type mapping remain provider-owned evidence in sync
- **Platform-core seams**: local suppression truth, provider-missing truth, current backup eligibility, historical restore continuity, and operator-facing policy vocabulary
- **Neutral platform terms / contracts preserved**: `provider missing`, `ignored locally`, `current backup eligibility`, and `restore continuity`
- **Retained provider-specific semantics and why**: subtype filtering remains provider-specific inside sync because it already reflects supported endpoint scope
- **Bounded extraction or follow-up path**: `document-in-feature` for the narrow provider-presence wording now; `follow-up-spec` later if the broader lifecycle taxonomy or explicit provider-deleted distinction is approved
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The slice keeps historical `PolicyVersion` and `BackupItem` truth authoritative and does not invent a new shadow registry.
- Read/write separation: PASS. No new destructive flow, purge path, or customer-facing mutation surface is introduced.
- Graph contract path: PASS. The slice reuses the existing provider sync boundary and adds no new Graph family.
- Deterministic capabilities: PASS. Existing capability registries remain canonical.
- Workspace and tenant isolation: PASS. `workspace_id` and `tenant_id` remain required anchors on policy truth.
- RBAC-UX plane separation: PASS. The slice stays inside existing admin and tenant-scoped policy, backup, and restore surfaces.
- Destructive confirmation standard: PASS. Existing local ignore/restore destructive actions remain the only destructive family and keep confirmation plus server-side authorization.
- Global search safety: PASS. `PolicyResource` is already globally disabled and remains so.
- OperationRun / Ops-UX: PASS. No new run type is introduced; blocked backup/export states stop before run creation.
- Data minimization: PASS. Provider-missing copy stays calm and decision-first; no new raw payload exposure is added.
- Test governance (TEST-GOV-001): PASS. Planned proof stays inside focused feature and unit lanes.
- Proportionality / no premature abstraction: PASS. One timestamp and derived state family are the narrowest defensible shape.
- Persisted truth (PERSIST-001): PASS. One field is added to existing policy truth; no new table or artifact family is created.
- Behavioral state (STATE-001): PASS. The state family is derived from existing and new timestamps rather than adding a new enum/persistence layer.
- Provider boundary (PROV-001): PASS. Provider-specific result interpretation stays in sync; operator-facing language stays platform-neutral.
- Filament / Laravel planning contract: PASS. Filament remains v5 on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no panel change is planned, `PolicyResource` global search remains disabled, and no assets are expected.
**Gate evaluation**: PASS.
- The narrow path is defensible if implementation keeps `ignored_at` user-owned and `missing_from_provider_at` provider-observation-only.
- The plan fails the gate if it drifts into `SoftDeletes`, a new lifecycle service, a provider-deleted taxonomy, or multi-object rollout.
**Post-design re-check**: PASS. [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), and [contracts/provider-missing-policy-visibility.openapi.yaml](contracts/provider-missing-policy-visibility.openapi.yaml) are present and aligned with the spec package.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for sync behavior, policy UI, backup eligibility, restore continuity, and authorization continuity; Unit for policy badge/state or narrow derived helper behavior if one is introduced
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the behavior is server-side and already anchored in existing sync, policy, backup, and restore tests. Focused feature coverage plus one small unit seam is enough without browser cost.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php tests/Feature/PolicySyncServiceTest.php tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php tests/Feature/PolicySyncStartSurfaceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyProviderMissingUiTest.php tests/Feature/PolicyGeneralViewTest.php tests/Feature/BulkDeletePoliciesTest.php tests/Feature/BulkUnignorePoliciesTest.php tests/Unit/Badges/PolicyBadgesTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BulkExportToBackupTest.php tests/Feature/Filament/BackupCreationTest.php tests/Feature/Filament/BackupSetPolicyPickerTableTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing tenant, policy, backup-set, backup-item, restore-run, and policy-version fixtures
- **Expensive defaults or shared helper growth introduced?**: none expected; any new helper should stay policy-local and explicit
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native-filament relief for policy and backup surfaces; shared-detail-family proof for restore continuity
- **Closing validation and reviewer handoff**: rerun the commands above, verify sync never writes `ignored_at` for provider absence, verify missing policies remain visible, verify current backup/export blocks without starting a run, verify historical restore continuity remains selectable, and verify non-members or out-of-scope actors still resolve as `404`
- **Budget / baseline / trend follow-up**: none expected beyond small feature-local test additions
- **Review-stop questions**: hidden `ignored_at` writes left in sync, blocked backup/export starting a run anyway, restore continuity silently filtered, or provider-specific wording leaking into platform truth
- **Escalation path**: `document-in-feature` for bounded wording or audit metadata notes; `follow-up-spec` for broader lifecycle rollout; `reject-or-split` for SoftDeletes or new framework drift
- **Active feature PR close-out entry**: Guardrail / State Vocabulary
## Rollout & Risk Controls
- Keep policy truth anchored to the existing `policies` row; do not create a side table or ghost-policy registry.
- Keep local ignore and provider-missing semantics orthogonal. Sync may clear provider-missing on reappearance, but only an explicit user action clears local ignore.
- Keep current-state capture conservative. If the provider cannot currently supply live state for a policy, current backup/export should explain that and stop.
- Keep historical restore continuity permissive where `BackupItem` truth already exists.
- Keep global search posture, panel registration, and asset strategy unchanged.
## Project Structure
### Documentation (this feature)
```text
specs/261-provider-missing-policy-visibility/
├── checklists/
│ └── requirements.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── provider-missing-policy-visibility.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Resources/
│ │ ├── BackupSetResource.php
│ │ ├── PolicyResource.php
│ │ └── RestoreRunResource.php
│ ├── Jobs/
│ │ └── Operations/PolicyBulkDeleteWorkerJob.php
│ ├── Models/
│ │ └── Policy.php
│ ├── Services/
│ │ ├── Audit/
│ │ └── Intune/
│ │ ├── BackupService.php
│ │ └── PolicySyncService.php
│ └── Support/
│ ├── Audit/AuditActionId.php
│ └── Ui/
├── database/
│ └── migrations/
└── tests/
├── Feature/
│ ├── Filament/
│ ├── Jobs/
│ └── ...
└── Unit/
└── Badges/
```
## Complexity Tracking
- **New persistence**: 1 nullable timestamp on `policies`
- **New derived state family**: 1 provider-presence family layered onto the existing local ignore semantics
- **New routes/pages/panels/providers**: none
- **New assets**: none
- **New queues or long-running operations**: none
- **Expected implementation risk**: moderate, because multiple existing tests currently encode the conflated semantics and must be corrected together

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