chore: commit all local changes (automated by Copilot)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m49s

This commit is contained in:
Ahmed Darrazi 2026-05-02 21:00:28 +02:00
parent df5a0e067d
commit b5671cbf47
22 changed files with 2865 additions and 8 deletions

View File

@ -0,0 +1,719 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Resources\FindingExceptionResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class DecisionRegister extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Decision register';
protected static ?int $navigationSort = 6;
protected static ?string $title = 'Decision register';
protected static ?string $slug = 'governance/decisions';
protected string $view = 'filament.pages.governance.decision-register';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleDecisionTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $registerPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredRegisterPayload = null;
/**
* @var array<int, array<string, mixed>>|null
*/
private ?array $rowPayloadByExceptionId = null;
private ?Workspace $workspace = null;
public ?int $tenantId = null;
public string $registerState = 'open';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep tenant and register-state scope visible without introducing a second mutation surface.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The decision register keeps one dominant row action and avoids a More menu in v1.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The decision register is read-only and intentionally omits bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Filtered empty states stay truthful and provide one path back to the broader register scope.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
}
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspace = static::resolveWorkspaceFromRequest();
if (! $workspace instanceof Workspace) {
return false;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
return false;
}
if (static::hasRequestedTenantPrefilter()) {
return true;
}
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
if ($visibleTenants === []) {
return false;
}
if (request()->query('register_state') === 'recently_closed') {
return true;
}
return (int) (app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $visibleTenants,
registerState: 'open',
)['counts']['open'] ?? 0) > 0;
}
public function mount(): void
{
$this->mountInteractsWithTable();
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->registerState = $this->resolveRequestedRegisterState();
$this->ensureRegisterIsVisible();
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedRegisterState = array_key_exists('register_state', $overrides)
? $overrides['register_state']
: $this->registerState;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public function appliedScope(): array
{
return [
'workspace_label' => $this->workspace()?->name,
'tenant_label' => $this->selectedTenant()?->name,
'register_state_label' => $this->registerStateLabel($this->registerState),
'visible_count' => $this->registerPayload()['counts'][$this->registerState] ?? 0,
];
}
/**
* @return list<array{key: string, label: string, count: int}>
*/
public function availableRegisterStates(): array
{
$counts = $this->registerPayload()['counts'] ?? ['open' => 0, 'recently_closed' => 0];
return [
[
'key' => 'open',
'label' => 'Open decisions',
'count' => (int) ($counts['open'] ?? 0),
],
[
'key' => 'recently_closed',
'label' => 'Recently closed',
'count' => (int) ($counts['recently_closed'] ?? 0),
],
];
}
public function hasTenantPrefilter(): bool
{
return $this->selectedTenant() instanceof Tenant;
}
public function isActiveRegisterState(string $registerState): bool
{
return $this->registerState === $registerState;
}
public function emptyStateHeading(): string
{
if ($this->tenantFilterAloneExcludesRows()) {
return 'This tenant filter is hiding other visible decision follow-through';
}
if ($this->registerState === 'recently_closed') {
return 'No recently closed decisions match this filter right now.';
}
return 'No open decisions match this filter right now.';
}
public function emptyStateDescription(): string
{
if ($this->tenantFilterAloneExcludesRows()) {
return 'The current tenant scope is calm, but other visible tenants in this workspace still have open governance decisions.';
}
if ($this->registerState === 'recently_closed') {
return 'Switch back to open decisions to continue the current follow-through lane, or widen the tenant scope if you were filtering the register.';
}
return 'Try widening the tenant scope or switch to recently closed decisions if you are checking what was just finished.';
}
public function emptyStateActionLabel(): ?string
{
if ($this->tenantFilterAloneExcludesRows()) {
return 'Clear tenant filter';
}
if ($this->registerState === 'recently_closed') {
return 'Open current decisions';
}
return null;
}
public function emptyStateActionUrl(): ?string
{
if ($this->tenantFilterAloneExcludesRows()) {
return $this->pageUrl(['tenant' => null]);
}
if ($this->registerState === 'recently_closed') {
return $this->pageUrl(['register_state' => 'open']);
}
return null;
}
public function table(Table $table): Table
{
return $table
->query($this->tableQuery())
->defaultSort('review_due_at', 'asc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(null)
->columns([
TextColumn::make('tenant.name')
->label('Tenant')
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
->sortable(),
TextColumn::make('current_validity_state')
->label('Impact')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
TextColumn::make('owner.name')
->label('Owner')
->placeholder('—')
->toggleable(),
TextColumn::make('review_due_at')
->label('Review due')
->dateTime()
->since()
->placeholder('—')
->tooltip(fn (FindingException $record): ?string => $record->review_due_at?->toDayDateTimeString())
->sortable(),
TextColumn::make('proof_availability')
->label('Proof')
->state(function (FindingException $record): string {
$referenceCount = (int) data_get($record->evidence_summary ?? [], 'reference_count', 0);
return $referenceCount > 0
? $referenceCount.' evidence linked'
: 'No linked proof';
})
->wrap(),
TextColumn::make('next_action_label')
->label('Next action')
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['next_action_label'] ?? null)
->visible(fn (): bool => $this->registerState === 'open')
->wrap(),
TextColumn::make('closure_reason')
->label('Closure reason')
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['closure_reason'] ?? null)
->placeholder('—')
->visible(fn (): bool => $this->registerState === 'recently_closed')
->wrap(),
])
->actions([
Action::make('open_decision')
->label('Open decision')
->color('gray')
->url(fn (FindingException $record): ?string => $this->decisionUrl($record)),
])
->emptyStateHeading($this->emptyStateHeading())
->emptyStateDescription($this->emptyStateDescription())
->emptyStateActions($this->emptyStateActions());
}
/**
* @return list<Tables\Actions\Action>
*/
private function emptyStateActions(): array
{
$label = $this->emptyStateActionLabel();
$url = $this->emptyStateActionUrl();
if (! is_string($label) || ! is_string($url)) {
return [];
}
return [
Action::make('empty_state_scope_action')
->label($label)
->url($url),
];
}
/**
* @return Builder<FindingException>
*/
private function tableQuery(): Builder
{
$tenantIds = array_values(array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->currentScopeTenants(),
));
$query = FindingException::query()
->where('workspace_id', (int) $this->workspace()?->getKey())
->whereIn('tenant_id', $tenantIds)
->with(['tenant', 'owner', 'currentDecision']);
if ($this->registerState === 'recently_closed') {
return $query
->whereIn('status', [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
])
->whereHas('currentDecision', function (Builder $decisionQuery): void {
$decisionQuery->where('decided_at', '>=', now()->startOfDay()->subDays(30));
});
}
return $query
->whereNotIn('status', [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
]);
}
private function authorizeWorkspaceMembership(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function ensureRegisterIsVisible(): void
{
if ($this->visibleDecisionTenants() === []) {
abort(403);
}
if ($this->tenantId !== null || $this->registerState !== 'open') {
return;
}
if ((int) ($this->registerPayload()['counts']['open'] ?? 0) === 0) {
abort(403);
}
}
/**
* @return array<int, Tenant>
*/
private function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = static::resolveAuthorizedTenantsFor($user, $workspace);
}
/**
* @return array<int, Tenant>
*/
private function visibleDecisionTenants(): array
{
if ($this->visibleDecisionTenants !== null) {
return $this->visibleDecisionTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || ! $workspace instanceof Workspace || $tenants === []) {
return $this->visibleDecisionTenants = [];
}
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tenantId = (int) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function resolveRequestedRegisterState(): string
{
$registerState = request()->query('register_state');
if (! is_string($registerState)) {
return 'open';
}
return in_array($registerState, ['open', 'recently_closed'], true)
? $registerState
: 'open';
}
private static function hasRequestedTenantPrefilter(): bool
{
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
return is_string($requestedTenant) || is_numeric($requestedTenant);
}
private static function resolveWorkspaceFromRequest(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return array<int, Tenant>
*/
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
{
return $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
/**
* @param array<int, Tenant>|null $authorizedTenants
* @return array<int, Tenant>
*/
private static function resolveVisibleDecisionTenantsFor(User $user, Workspace $workspace, ?array $authorizedTenants = null): array
{
$tenants = $authorizedTenants ?? static::resolveAuthorizedTenantsFor($user, $workspace);
if ($tenants === []) {
return [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW),
));
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
return $this->workspace = static::resolveWorkspaceFromRequest();
}
private function selectedTenant(): ?Tenant
{
if (! is_int($this->tenantId)) {
return null;
}
foreach ($this->visibleDecisionTenants() as $tenant) {
if ((int) $tenant->getKey() === $this->tenantId) {
return $tenant;
}
}
return null;
}
/**
* @return array<int, Tenant>
*/
private function currentScopeTenants(): array
{
$selectedTenant = $this->selectedTenant();
if ($selectedTenant instanceof Tenant) {
return [$selectedTenant];
}
return $this->visibleDecisionTenants();
}
/**
* @return array<string, mixed>
*/
private function registerPayload(): array
{
if (is_array($this->registerPayload)) {
return $this->registerPayload;
}
$workspace = $this->workspace();
if (! $workspace instanceof Workspace) {
return $this->registerPayload = [
'rows' => [],
'counts' => ['open' => 0, 'recently_closed' => 0],
];
}
return $this->registerPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $this->currentScopeTenants(),
registerState: $this->registerState,
);
}
/**
* @return array<string, mixed>
*/
private function unfilteredRegisterPayload(): array
{
if (is_array($this->unfilteredRegisterPayload)) {
return $this->unfilteredRegisterPayload;
}
$workspace = $this->workspace();
if (! $workspace instanceof Workspace) {
return $this->unfilteredRegisterPayload = [
'rows' => [],
'counts' => ['open' => 0, 'recently_closed' => 0],
];
}
return $this->unfilteredRegisterPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $this->visibleDecisionTenants(),
registerState: 'open',
);
}
/**
* @return array<string, mixed>
*/
private function rowPayload(FindingException $record): array
{
if (! is_array($this->rowPayloadByExceptionId)) {
$this->rowPayloadByExceptionId = collect($this->registerPayload()['rows'] ?? [])
->keyBy('exception_id')
->all();
}
return $this->rowPayloadByExceptionId[(int) $record->getKey()] ?? [];
}
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->registerState !== 'open') {
return false;
}
if (($this->registerPayload()['rows'] ?? []) !== []) {
return false;
}
return (int) ($this->unfilteredRegisterPayload()['counts']['open'] ?? 0) > 0;
}
private function registerStateLabel(string $registerState): string
{
return match ($registerState) {
'recently_closed' => 'Recently closed',
default => 'Open decisions',
};
}
public function decisionUrl(FindingException $record): ?string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return null;
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
$this->navigationContext()->toQuery(),
);
}
private function navigationContext(): CanonicalNavigationContext
{
return CanonicalNavigationContext::forDecisionRegister(
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkUrl: $this->pageUrl(),
);
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
$queryString = http_build_query($query);
if ($queryString === '') {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.$queryString;
}
}

View File

@ -10,6 +10,7 @@
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
@ -36,7 +37,18 @@ protected function getHeaderActions(): array
$renewRule = GovernanceActionCatalog::rule('renew_exception');
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
return [
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('return_to_decision_register')
->label($navigationContext->backLinkLabel)
->icon('heroicon-o-arrow-left')
->color('gray')
->url($navigationContext->backLinkUrl);
}
return array_merge($actions, [
Action::make('renew_exception')
->label($renewRule->canonicalLabel)
->icon('heroicon-o-arrow-path')
@ -159,7 +171,18 @@ protected function getHeaderActions(): array
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
}),
];
]);
}
public function getSubheading(): ?string
{
$navigationContext = $this->navigationContext();
if ($navigationContext?->sourceSurface === 'governance.decision_register') {
return 'Opened from the workspace decision register. Use the back action to return to the same register scope.';
}
return null;
}
/**
@ -199,4 +222,9 @@ private function canManageRecord(): bool
&& $user->canAccessTenant($record->tenant)
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
}

View File

@ -213,6 +213,20 @@ public static function makeOpenInEntraAction(): Actions\Action
->openUrlInNewTab();
}
public static function makeMembershipsAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('memberships')
->label('Manage memberships')
->icon('heroicon-o-users')
->url(fn (Tenant $record): string => static::getUrl('memberships', ['record' => $record], panel: 'admin')),
)
->requireCapability(Capabilities::TENANT_MEMBERSHIP_VIEW)
->tooltip('You do not have permission to view tenant memberships.')
->preserveVisibility()
->apply();
}
public static function makeSyncTenantAction(): Actions\Action
{
return UiEnforcement::forAction(

View File

@ -2,7 +2,38 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use Filament\Actions\Action;
class ManageTenantMemberships extends ViewTenant
{
protected static ?string $title = 'Tenant memberships';
protected static ?string $title = 'Manage tenant memberships';
public function getSubheading(): ?string
{
return 'Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.';
}
protected function getHeaderWidgets(): array
{
return [];
}
protected function getHeaderActions(): array
{
$actions = array_values(array_filter(
parent::getHeaderActions(),
static fn ($action): bool => ! ($action instanceof Action && $action->getName() === 'memberships'),
));
array_unshift(
$actions,
Action::make('back_to_overview')
->label('Back to tenant overview')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')),
);
return $actions;
}
}

View File

@ -54,6 +54,7 @@ protected function getHeaderWidgets(): array
protected function getHeaderActions(): array
{
return array_values(array_filter([
TenantResource::makeMembershipsAction(),
Actions\ActionGroup::make([
TenantResource::makeAdminConsentAction(),
TenantResource::makeOpenInEntraAction(),

View File

@ -8,6 +8,7 @@
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\InventoryCoverage;
@ -184,6 +185,7 @@ public function panel(Panel $panel): Panel
WorkspaceSettings::class,
CrossTenantComparePage::class,
GovernanceInbox::class,
DecisionRegister::class,
FindingsHygieneReport::class,
FindingsIntakeQueue::class,
MyFindingsInbox::class,

View File

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Support\GovernanceDecisions;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Models\Workspace;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
final readonly class GovernanceDecisionRegisterBuilder
{
private const int RECENTLY_CLOSED_DAYS = 30;
/**
* @var list<string>
*/
private const array TERMINAL_STATUSES = [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
];
/**
* @param array<int, Tenant> $visibleTenants
* @return array{
* rows: list<array<string, mixed>>,
* counts: array{open: int, recently_closed: int},
* }
*/
public function build(Workspace $workspace, array $visibleTenants, string $registerState = 'open'): array
{
$visibleTenantIds = array_values(array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$visibleTenants,
));
if ($visibleTenantIds === []) {
return [
'rows' => [],
'counts' => [
'open' => 0,
'recently_closed' => 0,
],
];
}
$rows = FindingException::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $visibleTenantIds)
->with(['tenant:id,name', 'owner:id,name', 'currentDecision'])
->get()
->map(fn (FindingException $exception): ?array => $this->buildRow($exception))
->filter()
->values();
/** @var Collection<int, array<string, mixed>> $openRows */
$openRows = $rows
->where('register_state', 'open')
->sortBy([
['due_at', 'asc'],
['exception_id', 'asc'],
])
->values();
/** @var Collection<int, array<string, mixed>> $recentlyClosedRows */
$recentlyClosedRows = $rows
->where('register_state', 'recently_closed')
->sortByDesc('decision_at')
->values();
return [
'rows' => match ($registerState) {
'recently_closed' => $recentlyClosedRows->all(),
default => $openRows->all(),
},
'counts' => [
'open' => $openRows->count(),
'recently_closed' => $recentlyClosedRows->count(),
],
];
}
/**
* @return array<string, mixed>|null
*/
private function buildRow(FindingException $exception): ?array
{
$currentDecision = $exception->currentDecision;
if (! $currentDecision instanceof FindingExceptionDecision) {
return null;
}
$registerState = $this->resolveRegisterState($exception, $currentDecision);
if ($registerState === null) {
return null;
}
return [
'exception_id' => (int) $exception->getKey(),
'register_state' => $registerState,
'tenant_name' => $exception->tenant?->name,
'owner_name' => $exception->owner?->name,
'status' => (string) $exception->status,
'current_validity_state' => (string) $exception->current_validity_state,
'next_action_label' => $registerState === 'open'
? $this->resolveNextActionLabel($exception, $currentDecision)
: 'Decision closed',
'closure_reason' => $registerState === 'recently_closed'
? (string) $currentDecision->reason
: null,
'due_at' => $exception->review_due_at ?? $exception->expires_at,
'decision_at' => $currentDecision->decided_at,
];
}
private function resolveRegisterState(FindingException $exception, FindingExceptionDecision $currentDecision): ?string
{
$status = (string) $exception->status;
if (in_array($status, self::TERMINAL_STATUSES, true)) {
return $this->isRecentlyClosed($currentDecision->decided_at)
? 'recently_closed'
: null;
}
return 'open';
}
private function resolveNextActionLabel(FindingException $exception, FindingExceptionDecision $currentDecision): string
{
if ($exception->isPendingRenewal() || $currentDecision->decision_type === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED) {
return 'Review renewal';
}
if ($exception->isPending()) {
return 'Review approval';
}
return 'Review follow-up';
}
private function isRecentlyClosed(?CarbonInterface $decidedAt): bool
{
if (! $decidedAt instanceof CarbonInterface) {
return false;
}
return $decidedAt->greaterThanOrEqualTo(now()->startOfDay()->subDays(self::RECENTLY_CLOSED_DAYS));
}
}

View File

@ -84,6 +84,20 @@ public static function forGovernanceInbox(
);
}
public static function forDecisionRegister(
string $canonicalRouteName,
?int $tenantId,
string $backLinkUrl,
): self {
return new self(
sourceSurface: 'governance.decision_register',
canonicalRouteName: $canonicalRouteName,
tenantId: $tenantId,
backLinkLabel: 'Back to decision register',
backLinkUrl: $backLinkUrl,
);
}
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
{
return new self(

View File

@ -0,0 +1,71 @@
<x-filament-panels::page>
@php
$scope = $this->appliedScope();
$registerStates = $this->availableRegisterStates();
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
<x-filament::icon icon="heroicon-o-clipboard-document-check" class="h-3.5 w-3.5" />
Decision register
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
Decision register
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace register shows the current exception and accepted-risk decisions that need follow-through without opening a second approval lane.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
@if (filled($scope['workspace_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Workspace: {{ $scope['workspace_label'] }}
</span>
@endif
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Scope: {{ $scope['register_state_label'] ?? 'Open decisions' }}
</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Visible rows: {{ $scope['visible_count'] ?? 0 }}
</span>
@if (filled($scope['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
Tenant: {{ $scope['tenant_label'] }}
</span>
@endif
</div>
<div class="flex flex-wrap gap-2">
@foreach ($registerStates as $registerState)
<a
href="{{ $this->pageUrl(['register_state' => $registerState['key']]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->isActiveRegisterState($registerState['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
{{ $registerState['label'] }}
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $registerState['count'] }}</span>
</a>
@endforeach
</div>
@if ($this->hasTenantPrefilter())
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span>The register is currently filtered to one tenant.</span>
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Clear tenant filter
</a>
</div>
@endif
</div>
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->browser()->timeout(20_000);
uses(RefreshDatabase::class);
function spec265ApprovedFindingException(Tenant $tenant, User $requester): FindingException
{
$approver = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $approver,
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Spec265 browser smoke request.',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
return $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Spec265 browser smoke approval.',
]);
}
function spec265SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = ''): string
{
return route('admin.local.smoke-login', array_filter([
'email' => $user->email,
'tenant' => $tenant->external_id,
'workspace' => $tenant->workspace->slug,
'redirect' => $redirect,
], static fn (?string $value): bool => filled($value)));
}
it('smokes the decision register continuity to the existing exception detail page', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
spec265ApprovedFindingException($tenant, $user);
$decisionRegisterUrl = DecisionRegister::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
]);
visit(spec265SmokeLoginUrl($user, $tenant))
->waitForText('Dashboard')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit($decisionRegisterUrl)
->waitForText('Decision register')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('The register is currently filtered to one tenant.')
->assertSee($tenant->name)
->assertSee('Open decision')
->click('Open decision')
->waitForText('Opened from the workspace decision register')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Back to decision register')
->assertSee('Renew exception')
->assertSee('Revoke exception')
->click('Back to decision register')
->waitForText('Decision register')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('The register is currently filtered to one tenant.')
->assertSee($tenant->name)
->assertSee('Open decision');
});

View File

@ -28,14 +28,17 @@
$viewPage
->assertNoJavaScriptErrors()
->assertSee((string) $tenant->name)
->assertSee('Manage memberships')
->assertScript("document.body.innerText.includes('Add member')", false)
->assertScript("document.body.innerText.includes('browser-tenant-member@example.test')", false);
$membershipsPage = visit(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'));
$membershipsPage = $viewPage->click('Manage memberships');
$membershipsPage
->assertNoJavaScriptErrors()
->assertSee('Tenant memberships');
->assertSee('Manage tenant memberships')
->assertSee('Back to tenant overview')
->assertSee('Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.');
$membershipsPage->script(<<<'JS'
window.scrollTo(0, document.body.scrollHeight);
@ -44,7 +47,7 @@
$membershipsPage
->waitForText('Add member')
->assertNoJavaScriptErrors()
->assertSee('Memberships')
->assertSee('Manage tenant memberships')
->assertSee('Add member')
->assertSee('browser-tenant-member@example.test')
->assertSee('Change role')

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
it('keeps the decision register read-only with one dominant row action', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Read only boundary test',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Read only boundary test',
'metadata' => [],
'decided_at' => now()->subDay(),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Open decision')
->assertDontSee('Approve exception')
->assertDontSee('Reject exception')
->assertDontSee('Renew exception')
->assertDontSee('Revoke exception');
});
it('omits terminal decisions outside the 30 calendar day recently closed window', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$createTerminalException = function (string $status, string $reason, int $daysAgo) use ($tenant, $user): FindingException {
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => $status,
'current_validity_state' => $status === FindingException::STATUS_REJECTED
? FindingException::VALIDITY_REJECTED
: FindingException::VALIDITY_REVOKED,
'request_reason' => 'Recently closed boundary test',
'review_due_at' => now()->subDays($daysAgo + 1),
'rejected_at' => $status === FindingException::STATUS_REJECTED ? now()->subDays($daysAgo) : null,
'revoked_at' => $status === FindingException::STATUS_REVOKED ? now()->subDays($daysAgo) : null,
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => $status === FindingException::STATUS_REJECTED
? FindingExceptionDecision::TYPE_REJECTED
: FindingExceptionDecision::TYPE_REVOKED,
'reason' => $reason,
'metadata' => [],
'decided_at' => now()->subDays($daysAgo),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception;
};
$createTerminalException(FindingException::STATUS_REJECTED, 'Recent closure reason', 2);
$createTerminalException(FindingException::STATUS_REVOKED, 'Old closure reason', 45);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin', parameters: ['register_state' => 'recently_closed']))
->assertOk()
->assertSee('Recent closure reason')
->assertDontSee('Old closure reason');
});

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Resources\FindingExceptionResource;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('embeds decision register navigation context into open decision links', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Decision register continuity',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Decision register continuity',
'metadata' => [],
'decided_at' => now()->subDay(),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
$context = CanonicalNavigationContext::forDecisionRegister(
canonicalRouteName: DecisionRegister::getRouteName(),
tenantId: (int) $tenant->getKey(),
backLinkUrl: DecisionRegister::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
]),
);
$expectedDetailUrl =
FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant)
.'?'.http_build_query($context->toQuery());
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::withQueryParams([
'tenant_id' => (string) $tenant->getKey(),
])
->actingAs($user)
->test(DecisionRegister::class)
->assertSee('Decision register')
->assertSee('Open decision');
expect($component->instance()->decisionUrl($exception))
->toBe($expectedDetailUrl)
->toContain('nav%5Bback_label%5D=Back+to+decision+register')
->toContain('nav%5Bsource_surface%5D=governance.decision_register');
});

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('adds a decision register back action while keeping existing detail actions in place', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'approved_by_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Detail continuity context',
'approval_reason' => 'Active approval still visible',
'requested_at' => now()->subDays(5),
'approved_at' => now()->subDays(4),
'effective_from' => now()->subDays(4),
'review_due_at' => now()->addDays(2),
'expires_at' => now()->addDays(10),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_APPROVED,
'reason' => 'Approved for detail continuity test',
'metadata' => [],
'decided_at' => now()->subDays(4),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$context = CanonicalNavigationContext::forDecisionRegister(
canonicalRouteName: DecisionRegister::getRouteName(),
tenantId: (int) $tenant->getKey(),
backLinkUrl: DecisionRegister::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
]),
);
Livewire::withQueryParams($context->toQuery())
->test(ViewFindingException::class, ['record' => $exception->getKey()])
->assertOk()
->assertActionVisible('return_to_decision_register')
->assertActionVisible('renew_exception')
->assertActionVisible('revoke_exception')
->assertSee('Opened from the workspace decision register');
});

View File

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
afterEach(function (): void {
Filament::setCurrentPanel(null);
});
it('redirects decision register visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the decision register route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members with no visible decisions in the default unfiltered register', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertForbidden();
});
it('hides the decision register page when the default workspace register would resolve to 403', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin')
->assertOk();
$response->assertDontSee(DecisionRegister::getUrl(panel: 'admin'));
expect(DecisionRegister::canAccess())->toBeFalse();
});
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey())
->assertNotFound();
});
it('allows readonly tenant members to open the decision register when visible decisions exist', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
decisionRegisterAuthException(
tenant: $tenant,
actor: $user,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Visible approval request',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Decision register');
});
it('registers the decision register page once visible open decisions exist', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
decisionRegisterAuthException(
tenant: $tenant,
actor: $user,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Visible approval request',
);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin')
->assertOk();
$response->assertSee(DecisionRegister::getUrl(panel: 'admin'));
expect(DecisionRegister::canAccess())->toBeTrue();
});
function decisionRegisterAuthException(
Tenant $tenant,
User $actor,
string $status,
string $validityState,
string $decisionType,
string $decisionReason,
): FindingException {
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => (int) $actor->getKey(),
'status' => $status,
'current_validity_state' => $validityState,
'request_reason' => 'Decision register authorization test',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => $decisionType,
'reason' => $decisionReason,
'metadata' => [],
'decided_at' => now()->subDay(),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception->fresh(['currentDecision']);
}

View File

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders open and recently closed decision rows for visible tenants only', function (): void {
$visibleTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Visible Tenant',
'external_id' => 'visible-tenant',
]);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Tenant',
'external_id' => 'hidden-tenant',
]);
decisionRegisterPageException(
tenant: $visibleTenant,
actor: $user,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Visible approval request',
exceptionAttributes: [
'requested_at' => now()->subDays(2),
'review_due_at' => now()->addDay(),
],
decisionAttributes: [
'decided_at' => now()->subDays(2),
],
);
decisionRegisterPageException(
tenant: $visibleTenant,
actor: $user,
status: FindingException::STATUS_REJECTED,
validityState: FindingException::VALIDITY_REJECTED,
decisionType: FindingExceptionDecision::TYPE_REJECTED,
decisionReason: 'Recently rejected closure reason',
exceptionAttributes: [
'rejected_at' => now()->subDays(2),
'review_due_at' => now()->subDays(3),
],
decisionAttributes: [
'decided_at' => now()->subDays(2),
],
);
decisionRegisterPageException(
tenant: $hiddenTenant,
actor: $user,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Hidden tenant request',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Decision register')
->assertSee('Visible Tenant')
->assertSee('Review approval')
->assertSee('Open decision')
->assertDontSee('Recently rejected closure reason')
->assertDontSee('Hidden tenant request');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin').'?register_state=recently_closed')
->assertOk()
->assertSee('Recently rejected closure reason')
->assertDontSee('Visible approval request');
});
it('shows truthful filtered empty states for tenant and register-state filters', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
decisionRegisterPageException(
tenant: $bravoTenant,
actor: $user,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Bravo tenant request',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey())
->assertOk()
->assertSee('This tenant filter is hiding other visible decision follow-through')
->assertSee('Clear tenant filter');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin').'?register_state=recently_closed')
->assertOk()
->assertSee('No recently closed decisions match this filter right now.');
});
/**
* @param array<string, mixed> $exceptionAttributes
* @param array<string, mixed> $decisionAttributes
*/
function decisionRegisterPageException(
Tenant $tenant,
User $actor,
string $status,
string $validityState,
string $decisionType,
string $decisionReason,
array $exceptionAttributes = [],
array $decisionAttributes = [],
): FindingException {
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exception = FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => (int) $actor->getKey(),
'status' => $status,
'current_validity_state' => $validityState,
'request_reason' => 'Decision register page test',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
$decision = $exception->decisions()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => $decisionType,
'reason' => $decisionReason,
'metadata' => [],
'decided_at' => now()->subDay(),
], $decisionAttributes));
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception->fresh(['currentDecision']);
}

View File

@ -610,15 +610,23 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$membershipsUrl = TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin');
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
->assertOk()
->assertSee('Manage memberships')
->assertSee('href="'.$membershipsUrl.'"', false)
->assertDontSeeLivewire(TenantMembershipsRelationManager::class);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$membershipsPage = Livewire::actingAs($user)
->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()]);
->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('back_to_overview')
->assertActionDoesNotExist('memberships')
->assertActionExists('back_to_overview', fn ($action): bool => $action->getLabel() === 'Back to tenant overview'
&& $action->getUrl() === TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
expect($membershipsPage->instance()->getRelationManagers())
->toContain(TenantMembershipsRelationManager::class);
@ -626,6 +634,12 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'))
->assertOk()
->assertSee('Manage tenant memberships')
->assertSee('Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.')
->assertSee('Back to tenant overview')
->assertDontSeeLivewire(\App\Filament\Widgets\Tenant\RecentOperationsSummary::class)
->assertDontSeeLivewire(\App\Filament\Widgets\Tenant\TenantVerificationReport::class)
->assertDontSeeLivewire(\App\Filament\Widgets\Tenant\AdminRolesSummaryWidget::class)
->assertSeeLivewire(TenantMembershipsRelationManager::class);
});
@ -689,6 +703,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->assertActionVisible('syncTenant')
->assertActionVisible('verify')
->assertActionVisible('setup_rbac')
->assertActionVisible('memberships')
->assertActionVisible('refresh_rbac')
->assertActionVisible('archive');
@ -698,7 +713,15 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
$instance->cacheInteractsWithHeaderActions();
}
$headerGroups = collect($instance->getCachedHeaderActions())
$headerActions = $instance->getCachedHeaderActions();
$primaryHeaderActions = collect($headerActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
$headerGroups = collect($headerActions)
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
->mapWithKeys(static function (ActionGroup $group): array {
$actionNames = collect($group->getActions())
@ -722,6 +745,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->and($markFollowUpNeededAction)->not->toBeNull()
->and($markFollowUpNeededAction?->getName())->toBe('markFollowUpNeeded')
->and($markFollowUpNeededAction?->isConfirmationRequired())->toBeTrue()
->and($primaryHeaderActions)->toEqual(['memberships'])
->and(array_keys($headerGroups->all()))->toBe(['External links', 'Setup', 'Triage', 'Lifecycle'])
->and($headerGroups->get('External links'))->toEqualCanonicalizing(['admin_consent', 'open_in_entra'])
->and($headerGroups->get('Setup'))->toEqualCanonicalizing(['syncTenant', 'verify', 'setup_rbac', 'refresh_rbac'])

View File

@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds open and recently closed decision rows from current exception truth', function (): void {
$workspace = Workspace::factory()->create();
$owner = User::factory()->create(['name' => 'Decision Owner']);
$approver = User::factory()->create(['name' => 'Decision Approver']);
$visibleTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Visible Tenant',
'external_id' => 'visible-tenant',
]);
$hiddenTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Hidden Tenant',
'external_id' => 'hidden-tenant',
]);
$pendingApproval = makeFindingExceptionWithCurrentDecision(
tenant: $visibleTenant,
owner: $owner,
actor: $owner,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Pending workspace approval',
exceptionAttributes: [
'requested_at' => now()->subDays(2),
'review_due_at' => now()->addDay(),
],
decisionAttributes: [
'decided_at' => now()->subDays(2),
],
);
$followUpNeeded = makeFindingExceptionWithCurrentDecision(
tenant: $visibleTenant,
owner: $owner,
actor: $approver,
status: FindingException::STATUS_EXPIRING,
validityState: FindingException::VALIDITY_EXPIRING,
decisionType: FindingExceptionDecision::TYPE_APPROVED,
decisionReason: 'Approved until remediation completes',
exceptionAttributes: [
'approved_by_user_id' => (int) $approver->getKey(),
'approved_at' => now()->subDays(5),
'effective_from' => now()->subDays(5),
'expires_at' => now()->addDays(2),
'review_due_at' => now()->addDay(),
],
decisionAttributes: [
'decided_at' => now()->subDays(5),
],
);
$recentlyRejected = makeFindingExceptionWithCurrentDecision(
tenant: $visibleTenant,
owner: $owner,
actor: $approver,
status: FindingException::STATUS_REJECTED,
validityState: FindingException::VALIDITY_REJECTED,
decisionType: FindingExceptionDecision::TYPE_REJECTED,
decisionReason: 'Evidence bundle was incomplete',
exceptionAttributes: [
'approved_by_user_id' => (int) $approver->getKey(),
'rejected_at' => now()->subDays(3),
'review_due_at' => now()->subDays(4),
],
decisionAttributes: [
'decided_at' => now()->subDays(3),
],
);
makeFindingExceptionWithCurrentDecision(
tenant: $visibleTenant,
owner: $owner,
actor: $approver,
status: FindingException::STATUS_REVOKED,
validityState: FindingException::VALIDITY_REVOKED,
decisionType: FindingExceptionDecision::TYPE_REVOKED,
decisionReason: 'Closed long ago',
exceptionAttributes: [
'approved_by_user_id' => (int) $approver->getKey(),
'revoked_at' => now()->subDays(45),
'review_due_at' => now()->subDays(46),
],
decisionAttributes: [
'decided_at' => now()->subDays(45),
],
);
makeFindingExceptionWithCurrentDecision(
tenant: $hiddenTenant,
owner: $owner,
actor: $owner,
status: FindingException::STATUS_PENDING,
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
decisionReason: 'Hidden tenant request',
exceptionAttributes: [
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDays(2),
],
decisionAttributes: [
'decided_at' => now()->subDay(),
],
);
$builder = app(GovernanceDecisionRegisterBuilder::class);
$openPayload = $builder->build(
workspace: $workspace,
visibleTenants: [$visibleTenant],
registerState: 'open',
);
$openRows = collect($openPayload['rows'])->keyBy('exception_id');
expect($openPayload['counts'])->toMatchArray([
'open' => 2,
'recently_closed' => 1,
])
->and($openRows->keys()->all())->toBe([
(int) $pendingApproval->getKey(),
(int) $followUpNeeded->getKey(),
])
->and($openRows[(int) $pendingApproval->getKey()]['tenant_name'])->toBe('Visible Tenant')
->and($openRows[(int) $pendingApproval->getKey()]['owner_name'])->toBe('Decision Owner')
->and($openRows[(int) $pendingApproval->getKey()]['next_action_label'])->toBe('Review approval')
->and($openRows[(int) $followUpNeeded->getKey()]['next_action_label'])->toBe('Review follow-up');
$recentlyClosedPayload = $builder->build(
workspace: $workspace,
visibleTenants: [$visibleTenant],
registerState: 'recently_closed',
);
expect($recentlyClosedPayload['counts'])->toMatchArray([
'open' => 2,
'recently_closed' => 1,
])
->and(collect($recentlyClosedPayload['rows'])->pluck('exception_id')->all())->toBe([
(int) $recentlyRejected->getKey(),
])
->and($recentlyClosedPayload['rows'][0]['closure_reason'])->toBe('Evidence bundle was incomplete')
->and($recentlyClosedPayload['rows'][0]['status'])->toBe(FindingException::STATUS_REJECTED);
});
it('keeps missing owner visible instead of omitting follow-up-needed rows', function (): void {
$workspace = Workspace::factory()->create();
$requester = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$unownedException = makeFindingExceptionWithCurrentDecision(
tenant: $tenant,
owner: null,
actor: $requester,
status: FindingException::STATUS_EXPIRED,
validityState: FindingException::VALIDITY_EXPIRED,
decisionType: FindingExceptionDecision::TYPE_APPROVED,
decisionReason: 'Expired and needs a fresh decision',
exceptionAttributes: [
'requested_by_user_id' => (int) $requester->getKey(),
'owner_user_id' => null,
'approved_by_user_id' => (int) $requester->getKey(),
'approved_at' => now()->subDays(20),
'effective_from' => now()->subDays(20),
'expires_at' => now()->subDay(),
'review_due_at' => now()->subDays(2),
],
decisionAttributes: [
'decided_at' => now()->subDays(20),
],
);
$payload = app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: [$tenant],
registerState: 'open',
);
expect($payload['rows'])->toHaveCount(1)
->and($payload['rows'][0]['exception_id'])->toBe((int) $unownedException->getKey())
->and($payload['rows'][0]['owner_name'])->toBeNull()
->and($payload['rows'][0]['next_action_label'])->toBe('Review follow-up');
});
/**
* @param array<string, mixed> $exceptionAttributes
* @param array<string, mixed> $decisionAttributes
*/
function makeFindingExceptionWithCurrentDecision(
Tenant $tenant,
?User $owner,
User $actor,
string $status,
string $validityState,
string $decisionType,
string $decisionReason,
array $exceptionAttributes = [],
array $decisionAttributes = [],
): FindingException {
$requesterId = $exceptionAttributes['requested_by_user_id'] ?? (int) $actor->getKey();
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exception = FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => $requesterId,
'owner_user_id' => $owner?->getKey(),
'status' => $status,
'current_validity_state' => $validityState,
'request_reason' => 'Decision register test setup',
'requested_at' => now()->subDays(7),
'review_due_at' => now()->addDays(7),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
$decision = $exception->decisions()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => $decisionType,
'reason' => $decisionReason,
'metadata' => [],
'decided_at' => now()->subDays(7),
], $decisionAttributes));
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception->fresh(['tenant', 'owner', 'currentDecision']);
}

View File

@ -0,0 +1,57 @@
# Specification Quality Checklist: Decision Register & Approval Workflow v1
**Purpose**: Validate specification completeness and repo fit before implementation
**Created**: 2026-05-02
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The spec stays on one bounded manual-promotion follow-up over current `FindingException` and `FindingExceptionDecision` truth instead of inventing a second decision domain.
- [x] The spec is product- and behavior-oriented and does not read like a low-level implementation diff.
- [x] The spec explicitly names the repo-real foundations it builds on: accepted-risk lifecycle, append-only decision history, current exception-detail approval actions, and the adjacent decision-home specs.
- [x] Mandatory repo sections for scope, RBAC, disclosure, testing, proportionality, and candidate rationale are completed.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain.
- [x] Requirements are testable and bounded to current exception and accepted-risk decision truth only.
- [x] The package distinguishes default-unfiltered `403` access denial from user-applied filtered zero-result empty states.
- [x] The package explicitly carries the tenant-prefilter reset rule: clearing the tenant filter returns the page to the workspace-wide open register.
- [x] The spec explains what remains in scope versus what is intentionally deferred.
- [x] Acceptance scenarios cover register visibility, detail launch continuity, and bounded decision-history behavior.
- [x] Recently closed rows explicitly require closure reason in both the formal requirements and implementation tasks.
- [x] Edge cases cover hidden tenants, missing proof links, the 30-calendar-day recently-closed boundary, and missing owner context.
## Candidate Selection Gate
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md`.
- [x] The active queue is explicitly empty, so this package records itself as a deliberate manual promotion rather than an automatic next-best-prep target.
- [x] No existing spec package already covers this bounded decision-register slice; related specs `154`, `250`, and `257` are treated as context only.
- [x] The chosen slice is smaller and more repo-ready than the deferred alternatives because the repo already contains append-only decision persistence and the existing detail workflow.
## Feature Readiness
- [x] The package reuses current `finding_exception_decisions` truth and current exception detail actions instead of introducing a new decision table or approval engine.
- [x] The spec explicitly keeps the register read-only and detail-owned for mutations.
- [x] The spec forbids new panel, provider, global-search, asset, queue, and persistence changes.
- [x] The plan and tasks keep any related review or run link optional and derived from existing proof only.
- [x] The register is explicitly planned as a Filament-native table or list surface, and later implementation review must use `docs/product/standards/list-surface-review-checklist.md`.
- [x] Decision-surface proof stays explicit: one dominant next action, diagnostics-secondary treatment, and no duplicate visible decision summary between register and detail.
## Test Governance
- [x] Planned proof stays bounded to focused `Unit` plus `Feature` families with one later manual smoke path.
- [x] No new heavy-governance or dedicated browser family is introduced.
- [x] Runtime proof commands stay consistent across spec, plan, and tasks, including `FindingExceptionDecisionRegisterBoundariesTest`, while Pint remains standard implementation hygiene.
## Notes
- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `specs/154-finding-risk-acceptance/spec.md`, `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, current finding-exception models and services under `apps/platform`, and `.specify/memory/constitution.md` on 2026-05-02.
- No application implementation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Outcome**: `keep`
- **Reason**: The selected slice is an explicit manual promotion, but it stays on repo-real exception-decision truth, narrows the broader candidate to one bounded register, and keeps all mutations on the current action-owning detail surface.
- **Workflow result**: Ready for implementation.

View File

@ -0,0 +1,269 @@
# Implementation Plan: Decision Register & Approval Workflow v1
**Branch**: `265-decision-register-approval` | **Date**: 2026-05-02 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/265-decision-register-approval/spec.md`
## Summary
This plan prepares one bounded operator follow-through slice over the repo's existing exception-decision truth. The implementation path is to add one new native Filament workspace page that hosts a Filament-native table or list surface, derives a decision register from `FindingException`, `FindingExceptionDecision`, current governance validity, owner or due context, and existing proof links, then launches into the current `ViewFindingException` detail surface for proof and action. The slice must stay on the existing exception decision domain, existing detail actions, and current audit semantics with no new persistence, no new approval engine, and no new queue or `OperationRun` family.
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no asset registration change is expected.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- `FindingException` and `FindingExceptionDecision` already provide append-only accepted-risk governance truth.
- `FindingExceptionService` already owns request, approve, reject, renew, and revoke lifecycle behavior.
- `FindingExceptionsQueue` already acts as the current queue-review workbench.
- `ViewFindingException` already owns proof and mutation actions for the exception lifecycle.
- `CanonicalNavigationContext`, `OperationRunLinks`, and `BadgeRenderer` already provide the relevant shared navigation, proof-link, and badge semantics.
### Explicit delta in this plan
- add one derived workspace `Decision register` page over current exception-decision truth
- add one bounded derived row or builder seam for owner, due-date, next-action, and proof-link assembly
- preserve the existing exception detail page as the only approval and closure surface
- keep any review or run link optional and derived only when current repo truth already exposes it
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: existing finding-exception models and services, native Filament pages, `CanonicalNavigationContext`, `OperationRunLinks`, `BadgeRenderer`, Pest v4
**Storage**: PostgreSQL via existing `finding_exceptions`, `finding_exception_decisions`, `finding_exception_evidence_references`, `findings`, `audit_logs`, and optional linked `tenant_reviews`, `review_packs`, and `operation_runs` only
**Testing**: Pest `Unit` plus `Feature` coverage, plus one narrow manual smoke in the later implementation loop
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: existing Laravel monolith in `apps/platform`, admin plane only (`/admin`)
**Project Type**: Web application (Laravel monolith with Filament pages)
**Performance Goals**: derived DB-backed register rendering, no new Graph calls, no queue-start path, and no new cached projection
**Constraints**: no new persisted decision entity, no generic workflow engine, no new customer-facing register, no inline register mutations, no new run type, no duplicate truth between register and detail
**Scale/Scope**: one new workspace page, one bounded builder seam, one existing detail page continuity path
## Likely Affected Repo Surfaces
- `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php`
- `apps/platform/resources/views/filament/pages/governance/decision-register.blade.php`
- `apps/platform/app/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilder.php`
- `apps/platform/app/Models/FindingException.php`
- `apps/platform/app/Models/FindingExceptionDecision.php`
- `apps/platform/app/Services/Findings/FindingExceptionService.php`
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- `apps/platform/tests/Unit/Support/GovernanceDecisions/...`
- `apps/platform/tests/Feature/Governance/...`
- `apps/platform/tests/Feature/Findings/...`
## UI / Filament & Livewire Fit
- Add one native Filament page under the existing admin governance navigation. The register itself must use Filament-native table or list primitives. Do not add a new resource, new detail shell, or second queue page.
- Keep the new page read-first. It may show one dominant `Open decision` row action and bounded proof-link indicators, but it must not host approval, rejection, renewal, or revocation actions directly.
- If `decision-register.blade.php` exists, keep it as a thin host for the native Filament surface rather than bespoke decision-row markup.
- Keep the exception detail page as the action owner. Any new context or summary added there must deepen the chosen decision rather than restating the workspace register.
- Keep filter state query-backed and public. Avoid private Livewire-only state for tenant and register-state filters.
- Clearing a tenant prefilter must return the actor to the workspace-wide open register instead of leaving the page stranded on a tenant-scoped zero state.
- Use existing badge semantics and existing action-surface rules. Do not introduce page-local semantic color logic or bespoke workflow cards if current Filament primitives and shared helpers are sufficient.
- The finished implementation must pass `docs/product/standards/list-surface-review-checklist.md`, or explicitly document a narrow exception.
- No new asset registration, panel setup, or provider registration change is planned.
## RBAC / Policy Fit
- Workspace membership remains the first gate for the register page.
- Register rows and counts must derive only from decisions the actor can already view under the existing exception-visibility contract.
- Existing decision actions remain on the current detail surface and keep their existing authorization paths.
- Non-members and explicit out-of-scope tenant or record targets stay `404`; in-scope members with no visible decisions in the default unfiltered register stay `403`; user-applied filters that narrow an already-authorized register view to zero rows show a truthful filtered empty state.
- Any optional related review or pack proof link must reuse current review visibility checks instead of inventing a new capability family.
## Audit / Logging Fit
- Existing append-only `FindingExceptionDecision` history remains the system of record.
- Existing `AuditLog` entries remain authoritative for decision actions; do not create a new page-view audit stream for the register itself.
- Existing exception detail actions keep their current audit IDs and proof semantics.
## Data & Query Fit
- Prefer one bounded builder or query helper that assembles register rows from the existing exception domain instead of storing a second decision summary.
- Define `recently closed` as only current terminal exception states (`rejected`, `revoked`, `superseded`) whose current terminal decision timestamp is within the last 30 calendar days.
- Derive register-state filters from current status, current decision type, validity, and bounded recent-history windows. Do not add a persisted `register_state` field.
- Eager-load the current decision, tenant, finding, owner, and evidence references needed for honest rows and avoid N+1 lookups.
- If a later implementation wants to expose related review or run links, derive them from current review or run truth only when already available. No new relation table or backfill process is allowed.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: governance decision home, proof drill-through, badge reuse, navigation continuity
- **State layers in scope**: page, URL-query, detail launch context
- **Audience modes in scope**: operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the register, diagnostics-second on the detail page, raw/support detail third
- **Raw/support gating plan**: raw and support detail stay on the detail surface and remain secondary or capability-gated
- **One-primary-action / duplicate-truth control**: register rows keep one dominant `Open decision` action; detail pages keep one current state-appropriate lifecycle action and must not duplicate the workspace summary
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
- **Decision-first proof obligations carried into implementation**: one dominant next action per row, diagnostics-secondary treatment, raw or support detail excluded from the register, and no duplicate visible decision summary between register and detail
- **List-surface review requirement**: pass `docs/product/standards/list-surface-review-checklist.md` and record any exception explicitly
- **Exception path and spread control**: none planned; any new decision engine or inline mutation surface is a scope split
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: current finding-exception detail and queue surfaces, governance navigation, proof links, badges, and audit-aware decision disclosure
- **Shared abstractions reused**: `CanonicalNavigationContext`, `OperationRunLinks`, `BadgeRenderer`, current exception-detail action surface, and current `FindingExceptionService` lifecycle rules
- **New abstraction introduced? why?**: one bounded register builder is acceptable if needed to assemble row state, next action, and proof links without bloating the page class
- **Why the existing abstraction was sufficient or insufficient**: the repo already has the truth and actions, but it lacks one bounded register view that uses them as a decision workflow instead of separate queue and detail fragments
- **Bounded deviation / spread control**: the new builder, if introduced, must stay under `Support/GovernanceDecisions/` and remain specific to this register rather than becoming a shared workflow framework
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, deep-link only when current proof already has a related run
- **Central contract reused**: `OperationRunLinks`
- **Delegated UX behaviors**: existing `Open operation` URL resolution only; no new toasts, notifications, or dedupe messaging
- **Surface-owned behavior kept local**: the register may indicate proof-link availability, but it must not start or queue anything
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: governance decision wording, owner or due context, proof-link semantics, and action labels
- **Neutral platform terms / contracts preserved**: decision register, open decision, recently closed, due review, follow-up needed
- **Retained provider-specific semantics and why**: none new
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before implementation begins and again before merge.*
- Inventory-first: PASS. Register rows stay derived from existing exception and finding truth.
- Read/write separation: PASS. The register remains read-only and current detail surfaces keep all mutations.
- Graph contract path: PASS. No Graph or provider call is introduced.
- Deterministic capabilities: PASS. Existing capability checks remain authoritative.
- Workspace and tenant isolation: PASS. Counts and rows derive after tenant and capability filtering.
- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion.
- Destructive action discipline: PASS. Existing risky actions stay on current detail pages and keep their current confirmation rules.
- Global search: PASS. No new resource or search result is introduced.
- OperationRun / Ops-UX: PASS by non-use. Existing run links stay optional proof only.
- Data minimization: PASS. The register shows only decision-first row content.
- Test governance: PASS. Proof stays in focused `Unit` and `Feature` lanes plus later manual smoke.
- Proportionality / no premature abstraction: PASS. The plan adds one page and at most one bounded builder instead of a workflow platform.
- Persisted truth: PASS. No new table or projection store is allowed.
- Behavioral state: PASS. Any register-state key stays derived and local.
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing detail actions, badges, and navigation helpers are reused.
- Provider boundary: PASS. No provider/platform seam widens.
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
**Gate evaluation**: PASS.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for row assembly and state mapping, `Feature` for register visibility, filters, launch continuity, and detail-surface interaction boundaries
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves the derived contract cheaply; feature coverage proves tenant-safe visibility and action-surface continuity on native Filament pages without introducing a dedicated 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/Unit/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/DecisionRegisterPageTest.php tests/Feature/Governance/DecisionRegisterAuthorizationTest.php tests/Feature/Findings/FindingExceptionDecisionRegisterNavigationTest.php tests/Feature/Findings/FindingExceptionDetailDecisionSummaryTest.php tests/Feature/Findings/FindingExceptionDecisionRegisterBoundariesTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse current finding-exception factories and visible or hidden tenant context without widening to queue, review-pack, or provider-heavy defaults
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `global-context-shell`
- **Closing validation and reviewer handoff**: reviewers should re-run the focused commands above, then manually smoke the new page by opening the register, applying a tenant filter, opening one decision, confirming one dominant next action and diagnostics-secondary treatment, and returning to the same register scope. Reviewers must also verify the implementation against `docs/product/standards/list-surface-review-checklist.md`.
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
- **Review-stop questions**: lane fit, hidden tenant leakage, accidental inline mutation surface, accidental second decision store, and proof-link honesty
- **Escalation path**: `reject-or-split` for any new decision engine or persistence; otherwise none
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Test-governance outcome**: keep
## Project Structure
### Documentation (this feature)
```text
specs/265-decision-register-approval/
├── spec.md
├── plan.md
├── tasks.md
└── checklists/
└── requirements.md
```
This preparation package intentionally stays on the core artifacts plus the readiness checklist. The repo already contains the relevant code, truth model, and adjacent specs, so no extra research, data-model, or contract package is required for a bounded implementation handoff.
### Source Code (expected implementation surfaces)
```text
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
apps/platform/resources/views/filament/pages/governance/decision-register.blade.php
apps/platform/app/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilder.php
apps/platform/app/Models/FindingException.php
apps/platform/app/Models/FindingExceptionDecision.php
apps/platform/app/Services/Findings/FindingExceptionService.php
apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
apps/platform/app/Support/OperationRunLinks.php
apps/platform/app/Filament/Resources/FindingExceptionResource.php
apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php
apps/platform/tests/Unit/Support/GovernanceDecisions/...
apps/platform/tests/Feature/Governance/...
apps/platform/tests/Feature/Findings/...
```
**Structure Decision**: keep implementation inside the existing admin-plane governance and findings seams. If a dedicated builder is needed, place it under `Support/GovernanceDecisions/` and keep it local to this register.
## Data / Migration Implications
- No migration or new table is planned.
- Prefer deriving row state, closure reason for recently closed rows, and next action from current exception-decision history and current exception fields.
- If implementation discovers a missing piece for display, prefer a bounded derived helper or existing metadata enrichment over a new decision entity or projection store.
- No new cache layer, backfill, or index migration should be required for v1.
## Rollout Considerations
- Filament remains v5 on Livewire v4. No panel-provider change is required, and provider registration remains in `apps/platform/bootstrap/providers.php`.
- No global search change is required because the slice adds one page, not a new searchable resource.
- No destructive action is added to the new page. Existing detail actions remain the only state-changing path and keep their current authorization and confirmation rules.
- No new asset registration is expected.
## Risk Controls
- Reject any implementation that introduces a new `GovernanceDecision` model, register cache, or generic workflow engine.
- Reject any implementation that adds inline register mutations or a second approval surface.
- Reject any implementation that widens the slice into alerts, operations, or customer-facing decision pages.
- Reject any implementation that invents proof by generating new reviews or runs from the register.
## Implementation Phases
### Phase 0 - Confirm Current Decision Truth
- Verify the current exception lifecycle, current decision history, and current detail-surface actions in the existing models, services, pages, and tests.
- Verify which proof links already exist and stay truthful in the current repo.
### Phase 1 - Add The Derived Register Surface
- Create the new page class and bounded register builder.
- Derive open and recently-closed row states from current exception and decision truth only.
- Render one dominant `Open decision` action per row with honest filter and empty-state behavior.
### Phase 2 - Wire Detail Continuity
- Launch into the existing `ViewFindingException` detail page with preserved tenant and return context.
- Keep the detail page as the only approval and closure surface.
### Phase 3 - Harden Proof And Boundary Rules
- Add optional evidence, review, or run links only when current proof already exists.
- Confirm hidden-tenant omission, `404` vs `403`, and no second decision state.
### Phase 4 - Validate And Stop
- Run the focused `Unit` and `Feature` proof.
- Perform one narrow register-to-detail smoke path.
- Confirm no new panel, no new asset strategy, no new queue or run family, and no new persistence.
## Why This Plan Is Narrow Enough
The repo already has append-only exception-decision history, existing detail-page approval actions, and the shared helpers needed for navigation, proof links, and badge semantics. This plan adds only the missing workspace register over that truth and preserves the current detail page as the owning action surface. Everything broader remains explicitly deferred.

View File

@ -0,0 +1,297 @@
# Feature Specification: Decision Register & Approval Workflow v1
**Feature Branch**: `265-decision-register-approval`
**Created**: 2026-05-02
**Status**: Ready for implementation
**Input**: User description: "Prepare Decision Register & Approval Workflow v1 so workspace operators can review, own, close, and audit exception and accepted-risk governance decisions from one bounded register without inventing a generic task engine or broader workflow platform."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has repo-real accepted-risk lifecycle truth through `FindingException`, append-only `FindingExceptionDecision` history, existing approval actions on the exception detail flow, and decision-entry surfaces such as `GovernanceInbox` and `FindingExceptionsQueue`, but operators still lack one calm register that answers which governance decisions need approval, renewal, review, or closure now.
- **Today's failure**: Operators still reconstruct decision follow-through by hopping between the exception queue, exception detail, customer-safe review context, and audit history. Ownership, due date, closure reason, and next action remain harder to scan than they should be, which makes expiring or lapsed accepted-risk governance feel like scattered admin detail instead of an explicit decision workflow.
- **User-visible improvement**: One workspace decision register shows the active and recently closed accepted-risk governance decisions for visible tenants, including owner, due or review date, current decision state, impact, next action, and links into the existing proof or approval surfaces.
- **Smallest enterprise-capable version**: One new workspace-level decision register page under `/admin` that hosts a Filament-native table or list surface, derives rows from current `FindingException`, `FindingExceptionDecision`, and current governance validity truth, defaults to open decisions, exposes a bounded recently-closed view for decisions closed within the last 30 calendar days, and deep-links to the existing `ViewFindingException` detail page for proof and approval actions. No inline approval on the register itself.
- **Explicit non-goals**: No new `GovernanceDecision` table; no generic task engine; no new cross-family queue for alerts, operations, and reviews; no customer-facing decision portal; no inline approve, reject, renew, or revoke actions on the register page; no autonomous escalation; no new review-pack workflow; no new OperationRun start path.
- **Permanent complexity imported**: One new native Filament page, one bounded derived register-builder seam, one local derived register-state filter family, optional launch or return-context handling, and focused `Unit` plus `Feature` coverage.
- **Why now**: `docs/product/spec-candidates.md` and `docs/product/roadmap.md` both identify the decision register as the highest-priority manual-promotion gap after the automatic queue was deliberately exhausted. The repo already has the exception-decision persistence and service logic, so the next product value is productizing that truth, not creating more foundations.
- **Why not local**: Extending only `FindingExceptionsQueue` or only `ViewFindingException` would keep the current multi-surface reconstruction problem intact and would not provide a single truthful answer to `which decision needs follow-through now?`.
- **Approval class**: Workflow Compression
- **Red flags triggered**: One multi-surface follow-through flag and one new workspace register surface flag. Defense: the slice stays on existing decision truth, introduces no new persistence, and refuses a generic workflow engine.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- new canonical workspace route `/admin/governance/decisions`
- existing tenant-scoped `FindingExceptionResource` list and view routes as the owning proof and approval surfaces
- existing `FindingExceptionsQueue` route as contextual queue input only
- existing `GovernanceInbox` route as contextual launch or return input only when later connected
- **Data Ownership**:
- tenant-owned `Finding`, `FindingException`, `FindingExceptionDecision`, and `FindingExceptionEvidenceReference` remain the only persisted truth for the decision rows
- existing `AuditLog` entries remain the only audit truth for decision actions
- existing `TenantReview`, `ReviewPack`, and `OperationRun` records remain secondary linked proof only when current repo truth already exposes them
- the decision register introduces no new table, cache, mirror entity, or workflow-state persistence
- **RBAC**:
- workspace membership remains the first boundary for the register page
- register visibility reuses existing exception visibility semantics through `Capabilities::FINDING_EXCEPTION_VIEW`
- approval, rejection, renewal, and revocation remain on the existing exception detail surface and continue to require the current approval capability family such as `Capabilities::FINDING_EXCEPTION_APPROVE`
- if a row exposes a related review or pack link, that link appears only when the actor already satisfies the existing review visibility checks
- non-members and explicit out-of-scope tenant targets remain `404` deny-as-not-found boundaries
- in-scope workspace members with no visible decision rows in the default unfiltered register receive `403`, not a silent empty shell
- once an entitled actor is on the register, user-applied tenant or register-state filters that narrow visible rows to zero show a truthful filtered empty state instead of escalating to `403`
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: when launched from a tenant-scoped exception or review surface, the decision register prefilters to that tenant and keeps `register_state=open` as the default. Clearing the tenant filter returns to the workspace-wide open register rather than freezing the page on one tenant forever.
- **Explicit entitlement checks preventing cross-tenant leakage**: broad register counts and rows omit hidden tenants and hidden proof links before counts are derived. Explicit tenant filters or explicit decision targets outside visible scope resolve as not found and do not reveal whether another tenant has pending or closed decisions.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, governance queues or registers, action links, status messaging, badge semantics, evidence and proof drill-through, and audit-aware decision history disclosure
- **Systems touched**: `FindingExceptionsQueue`, `FindingExceptionResource`, `ViewFindingException`, `GovernanceInbox`, `CanonicalNavigationContext`, `OperationRunLinks`, `BadgeRenderer`, the current finding-exception detail action surface, and current audit-trail presentation
- **Existing pattern(s) to extend**: existing exception-decision append-only history, current exception-detail approval actions, current queue and detail launch conventions, and current governance navigation continuity
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `OperationRunLinks`, `BadgeRenderer`, existing `FindingExceptionService` lifecycle semantics, and current action-surface declarations on the exception pages
- **Why the existing shared path is sufficient or insufficient**: the repo already has the underlying decision truth and approval actions, but the current shared surfaces are insufficient as a bounded register because they answer queue work or record proof, not one calm workspace closure view.
- **Allowed deviation and why**: none planned. If implementation needs one local register builder, it must stay page-scoped and derived from current exception truth instead of becoming a reusable workflow engine.
- **Consistency impact**: `Decision register`, `Open decision`, decision-state wording, owner or due-date language, badge meaning, and proof-link labels must stay aligned with the current exception detail and queue surfaces.
- **Review focus**: reviewers must block any implementation that introduces a generic decision framework, a second approval action surface, or a second persisted decision summary.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes, deep-link only when current proof already has a related run
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks`
- **Delegated start/completion UX behaviors**: existing `Open operation` link resolution only when a current related run already exists. No queued toast, run-enqueued browser event, dedupe message, or terminal notification behavior is introduced.
- **Local surface-owned behavior that remains**: the register may show whether a related proof link exists, but all decision lifecycle actions remain DB-backed and detail-surface-owned.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider or platform-core seam is widened. The slice only productizes existing governance decision truth in the admin plane.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Governance decision register page | yes | Native Filament page plus shared badges and navigation helpers | workspace decision home, queue-to-detail drill-through, proof-link semantics | page, URL-query | no | One new bounded register over existing exception-decision truth |
| Finding exception detail page | yes | Native Filament resource page | proof surface, current approval actions, launch and return context | page, detail, URL-query | no | Remains the only owning approval surface |
Implementation intent: the new register is a Filament-native table or list surface hosted by a page. If `decision-register.blade.php` is needed, it stays a thin page wrapper around native Filament table primitives instead of bespoke row markup. The later implementation review must explicitly pass `docs/product/standards/list-surface-review-checklist.md`, or record a narrow exception with rationale.
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Governance decision register page | Primary Decision Surface | Operator decides which accepted-risk governance decision needs follow-through now | tenant, decision state, owner, due or review date, impact, and one dominant next action | full exception history, evidence references, audit trail, related review or run links after explicit open | Primary because it becomes the bounded closure surface for existing decision truth rather than another queue or record detail page | Follows `what governance decision needs action now?` before `what does this record contain?` | Replaces queue hopping and record-by-record search |
| Finding exception detail page | Secondary Context | Operator validates proof and performs the existing approval or closure action | current decision summary, current validity, evidence summary, and existing action hierarchy | full append-only decision history, evidence references, audit trail, and related proof links | Secondary because it remains the proof and action owner after the register chooses the record | Keeps action and proof inside the current exception workflow | Prevents the register from becoming a duplicate mutation lane |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Governance decision register page | operator-MSP | decision status, owner, due or review date, impact, next action, and source tenant | deeper history, review context, and proof remain on the detail surface | raw JSON, copied payloads, and low-level audit metadata stay off the register page | `Open decision` | raw or support detail is not rendered on the register page | the register states the decision truth once and defers proof to the detail page |
| Finding exception detail page | operator-MSP | current decision, why it needs action, evidence summary, and the existing action hierarchy | append-only history, audit trail, and optional related proof links | low-level metadata and raw support context stay secondary and capability-gated | one existing state-appropriate action such as `Approve exception` or `Review renewal` | raw/support sections remain secondary | the detail page deepens the chosen decision and does not restate the workspace-wide register summary |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Governance decision register page | Utility / Workspace Decision | Read-only decision register | Open the correct decision for proof or approval | explicit row action into the existing detail surface | forbidden | filter controls and proof links only | none | `/admin/governance/decisions` | existing tenant-scoped `FindingExceptionResource` view route | active workspace, optional tenant filter, register-state filter | Decision register | which governance decision needs follow-through now | none |
| Finding exception detail page | Detail / Reviewable governance record | Action-owning detail surface | Approve, reject, renew, revoke, or confirm the current decision state | explicit open from register or queue | `N/A` | supporting links and secondary proof actions only | current risky actions remain grouped on the detail page and keep their current confirmation rules | existing tenant-scoped `FindingExceptionResource` list route | existing tenant-scoped `FindingExceptionResource` view route | tenant scope, decision status, current validity | Finding exception | current decision truth and actionability for the opened record | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance decision register page | Workspace operator / MSP operator | Decide which current governance decision needs follow-through | Workspace decision hub | Which accepted-risk or exception decision needs action now, who owns it, and where do I go next? | tenant, owner, due or review date, decision state, impact, next action, and proof availability | append-only history, evidence detail, audit, and optional review or run context | decision state, governance validity, due or review timing, impact | none on the register page itself | Open decision | none |
| Finding exception detail page | Workspace operator / approver | Validate proof and perform the current decision lifecycle action | Detail surface | Why is this decision in its current state, and what action is allowed now? | current decision summary, evidence summary, action-ready state, and current owner or due context | full decision history, deeper evidence, audit trail, and related proof links | decision lifecycle, governance validity, expiry, audit status | existing exception decision lifecycle only | existing detail-owned action such as approve, reject, renew, or revoke | current detail-owned risky actions only |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded register builder or assembler may be needed to derive row state, next action, and proof links from current exception decision truth
- **New enum/state/reason family?**: no persisted family; any register-state keys remain local derived filters only
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: operators cannot answer `which governance decision still needs action now?` from one bounded surface, even though current repo truth already stores append-only decision records and exception lifecycle state.
- **Existing structure is insufficient because**: the current queue and detail pages answer different parts of the workflow, but neither page gives one bounded cross-tenant closure view with owner, due date, and next action.
- **Narrowest correct implementation**: one derived workspace register over current `FindingException` and `FindingExceptionDecision` truth plus current detail-page launch continuity.
- **Ownership cost**: one new page, one derived row contract, filter state, and focused tests.
- **Alternative intentionally rejected**: a new `GovernanceDecision` entity or generic approval engine was rejected because the repo already has the exception-decision history and action seams needed for v1.
- **Release truth**: current-release workflow compression over existing decision truth, not future-release workflow-platform preparation.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, migration shims, legacy aliases, and a second decision-store fallback are out of scope unless explicitly required by this spec.
Canonical reuse of current exception-decision truth is preferred over a parallel decision domain.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: unit coverage can prove row-state derivation, omission rules, and next-action mapping cheaply. Focused feature coverage can prove workspace visibility, `404` vs `403`, tenant and state filter behavior, and register-to-detail launch continuity on native Filament surfaces. A new browser family is unnecessary for v1; one narrow manual smoke remains sufficient for the later implementation loop.
- **New or expanded test families**: one focused `Unit/Support/GovernanceDecisions` family and focused `Feature/Governance` plus `Feature/Findings` coverage
- **Fixture / helper cost impact**: moderate. Tests need visible and hidden tenants, exception decisions in pending, approved, renewal-requested, revoked, and expired states, plus current owner and evidence references, but they should reuse existing factories and avoid queue or browser-heavy defaults.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: required state-contract coverage for tenant-filter continuity, the 30-calendar-day recently-closed window, one dominant next action, diagnostics-secondary treatment, no duplicate visible decision summary, and register-to-detail launch continuity
- **Reviewer handoff**: reviewers must confirm that the register stays read-only, hidden tenants do not leak through counts or empty states, and existing detail actions remain the only approval surface.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/DecisionRegisterPageTest.php tests/Feature/Governance/DecisionRegisterAuthorizationTest.php tests/Feature/Findings/FindingExceptionDecisionRegisterNavigationTest.php tests/Feature/Findings/FindingExceptionDetailDecisionSummaryTest.php tests/Feature/Findings/FindingExceptionDecisionRegisterBoundariesTest.php`
## Scope Boundaries
### In Scope
- one workspace-level governance decision register in the existing admin plane
- default open-decision visibility plus one bounded recently-closed register-state view derived from current exception decision history
- row-level owner, due or review date, decision state, impact, next action, and proof-availability cues derived from current exception truth
- existing detail-surface launch continuity and truthful return context
- existing evidence, audit, and optional related proof links only when current repo truth already exposes them
### Non-Goals
- a new persisted decision table, cache, or projection store
- inline decision lifecycle mutations on the register page
- a generic workflow engine or cross-family register for alerts, operations, or reviews
- customer-facing decision pages or customer-side approval actions
- new review-pack generation, new queue family, or new OperationRun start semantics
- a governance inbox rewrite; any later inbox launch affordance is a follow-up, not a prerequisite for this slice
## Assumptions
- current `FindingException` and `FindingExceptionDecision` fields are sufficient to derive owner, due context, closure reason, and next action for the bounded register
- the current exception detail surface remains the correct owner for approval, rejection, renewal, and revocation actions
- existing audit and evidence references are sufficient for v1 proof links without a second summary store
- if a related review or run link is unavailable for a row, truthful omission is acceptable in v1
## Risks
- the slice could drift into a generic decision engine if implementation tries to absorb alerts, operations, and reviews as equal peers instead of staying on existing exception-decision truth
- if register rows try to solve missing proof by creating new persistence or mirrored summaries, the feature would violate proportionality and source-of-truth rules
- the detail page could become duplicative if it starts repeating the workspace-level register summary instead of deepening the chosen decision
## Candidate Selection Rationale
- **Selected candidate**: Decision Register & Approval Workflow v1
- **Source locations**:
- `docs/product/spec-candidates.md`
- `docs/product/roadmap.md`
- `docs/product/implementation-ledger.md`
- **Why selected**: the active auto-prep queue is intentionally empty, and this is the highest-priority manual-promotion backlog item. The repo already contains the key enabling truth: append-only `FindingExceptionDecision` history, existing exception approval actions, and decision-first governance surfaces.
- **Why this is the smallest viable implementation slice**: v1 stays on current accepted-risk and exception decision truth only, adds one bounded register, and reuses the existing detail-page approval workflow instead of inventing a broader multi-family closure engine.
- **Intentional narrowing from source candidate**: the roadmap candidate names owner, due date, status, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, escalation hook, and optional approval or closure semantics. This v1 narrows that list to the repo-real exception and accepted-risk decision family only, keeps related proof links optional and derived, and defers broader cross-family escalation or approval packages.
- **Why close alternatives were deferred**:
- `Governance Artifact Lifecycle & Retention v1` remains broader and more cross-domain than the current repo-ready decision seam.
- `Billing & Subscription Truth Layer v1` and `Customer-Facing Localization Adoption v1` remain valid manual promotions, but neither is as directly enabled by current persisted decision truth.
- `Enterprise Access Boundary & Support Access Governance v1` remains product-sensitive and less repo-ready than this bounded exception-decision slice.
## Follow-up Candidates
- governance inbox launch or summary integration once the bounded register is proven
- broader multi-family decision register work for alerts, operations, and review follow-up only after the exception-decision slice remains calm and bounded
- customer-safe awareness or delivery-pack follow-through for accepted-risk decisions only after the operator register proves the closure semantics
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See open governance decisions in one register (Priority: P1)
As a workspace operator, I want one register that shows the accepted-risk and exception decisions that still need approval, renewal, or review so I can decide where to work next without scanning several pages first.
**Why this priority**: This is the core product gap. Without one bounded register, the product still forces decision reconstruction across queue and detail pages.
**Independent Test**: Seed visible and hidden tenants with pending, expiring, lapsed, and recently closed exception decisions, open the register, and verify that the page shows only visible-tenant rows with owner, due context, decision state, and one dominant next action.
**Acceptance Scenarios**:
1. **Given** the actor has visible pending and expiring exception decisions across two visible tenants, **When** they open the decision register, **Then** the page shows separate rows with owner, due or review date, impact, and `Open decision` as the only dominant row action.
2. **Given** the actor has hidden-tenant decisions they are not entitled to see, **When** they open the decision register, **Then** those decisions do not affect visible counts, labels, or empty-state hints.
3. **Given** the actor applies a tenant filter that hides all current open rows, **When** they view the page, **Then** the page shows a truthful filtered empty state instead of implying the whole workspace is calm.
---
### User Story 2 - Open the existing approval surface with context (Priority: P1)
As a workspace operator, I want the register to open the current exception detail and approval surface with preserved context so the register stays a decision hub instead of becoming a second mutation lane.
**Why this priority**: The register only helps if the next click lands on the existing proof and action surface without losing context.
**Independent Test**: Open a decision row from the register, land on the existing exception detail page, verify the current approval or closure actions remain there, and return to the same register scope.
**Acceptance Scenarios**:
1. **Given** a pending exception decision is visible in the register, **When** the actor opens it, **Then** the destination is the existing exception detail page with the current approve or reject actions, not a new register-owned detail shell.
2. **Given** an expiring or lapsed governance decision is visible, **When** the actor opens it from the register, **Then** the detail page preserves tenant and return context and makes the current renewal or revocation path explicit through the existing action surface.
3. **Given** a row has no related review or run proof link, **When** the actor opens the decision, **Then** the detail page still loads truthfully and does not imply missing proof exists elsewhere.
---
### User Story 3 - Keep decision truth audit-safe and bounded (Priority: P2)
As a reviewer or operator, I want the register to remain derived from the existing exception-decision history, audit trail, and evidence references so the feature does not create a second workflow state or a hidden approval engine.
**Why this priority**: This protects the architecture and keeps the manual-promotion slice narrow enough to implement safely.
**Independent Test**: Inspect the register and detail behavior after implementation and verify that register rows derive from current exception-decision history, recently closed rows are limited to terminal decisions from the last 30 calendar days, and no new persisted decision summary exists.
**Acceptance Scenarios**:
1. **Given** an exception has more than one append-only decision row, **When** it appears in the register, **Then** the register derives its current row state from the current decision and current validity without collapsing or mutating history.
2. **Given** a rejected, revoked, or superseded decision has a current terminal decision timestamp inside the last 30 calendar days, **When** the actor switches the register to recently closed, **Then** the row shows closure reason from the current decision history without reopening a new workflow state.
3. **Given** a decision is approved but no longer has valid supporting governance, **When** it appears in the open register, **Then** the row is presented as follow-up-needed rather than calm or closed.
### Edge Cases
- What happens when the same finding has current decision history plus older request, approval, and revocation rows? The register must surface only the current derived row while the detail page preserves full append-only history.
- What happens at the recently-closed boundary? A row is eligible only when its current terminal decision timestamp is within the last 30 calendar days; rows older than that stay out of the register.
- What happens when an active decision has no owner assigned? The register should show the missing owner truthfully and treat it as follow-up-needed instead of hiding the row.
- What happens when a row has no related review or `OperationRun` link? The register omits the proof link and does not start or generate anything.
- What happens when the actor has access to only one tenant from a multi-tenant workspace? The register must stay honest and derive counts only from that visible tenant.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Graph calls and no new remote or queued work. It reuses the existing DB-backed accepted-risk decision workflow, current audit trail, current capability checks, and current tenant-safe proof links.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature must not add a new persisted decision entity, decision summary cache, or generic workflow layer. Any new builder or helper must stay bounded to the register page and current exception decision truth.
**Constitution alignment (XCUT-001):** The feature must extend the current navigation, badge, proof-link, and exception-detail interaction families instead of inventing parallel local patterns.
- **FR-001**: The system MUST provide one workspace-level `Decision register` page in the existing admin plane that lists the current accepted-risk and exception decisions visible to the actor.
- **FR-002**: Each register row MUST derive from existing `FindingException`, `FindingExceptionDecision`, and current governance validity truth and MUST NOT create a second persisted decision summary.
- **FR-003**: The register MUST provide a default open-decision view and a bounded recently-closed view. The recently-closed view MUST show only rows whose current exception state is terminal (`rejected`, `revoked`, or `superseded`) and whose current terminal decision timestamp falls within the last 30 calendar days; anything older stays out of the register.
- **FR-004**: Each visible row MUST show tenant, current decision state, owner, due or review date when present, impact cue, next action cue, and the dominant `Open decision` action. Rows in the recently-closed view MUST also show closure reason derived from the current terminal decision.
- **FR-005**: The register page MUST stay read-only. Approval, rejection, renewal, revocation, or any other mutation MUST remain on the existing exception detail surface.
- **FR-006**: Opening a row from the register MUST land on the existing exception detail page with truthful tenant and return context preserved.
- **FR-007**: Non-members and out-of-scope tenant targets MUST resolve as `404`; in-scope members with no visible decision rows in the default unfiltered register MUST receive `403`; user-applied tenant or register-state filters that narrow an already-authorized register view to zero rows MUST show a truthful filtered empty state.
- **FR-008**: Hidden tenants, hidden decision rows, and hidden proof links MUST be omitted before counts, labels, and empty states are derived.
- **FR-009**: If current repo truth already exposes evidence references, related reviews, or related runs for a decision, the register or detail surface MAY link to them through existing helpers. Missing proof MUST remain truthful and MUST NOT trigger generation or a new `OperationRun`.
- **FR-010**: The feature MUST keep current append-only decision history, existing audit log behavior, and current detail action confirmations authoritative. It MUST NOT introduce a second approval engine, a second audit family, or a local register mutation lane.
- **FR-011**: The register MUST be implemented as a Filament-native table or list surface and later reviewed against `docs/product/standards/list-surface-review-checklist.md`. Any deviation from that checklist MUST be explicit and justified.
## Acceptance Criteria
- The selected candidate is narrowed to current exception and accepted-risk decision truth only.
- The prep package names real repo surfaces and keeps implementation bounded to existing models, services, detail pages, and shared helpers.
- No new persisted decision entity or generic workflow framework is required by the prepared spec.
- The implementation plan and task list include explicit RBAC, test, and smoke-validation coverage for the register and detail-launch flow.
## Success Criteria
- A later implementation can deliver the first decision-follow-through slice without inventing new persistence or reopening current exception lifecycle truth.
- Reviewers can point to one primary operator question for the new page and one existing detail surface that owns mutations.
- The package is explicit enough that a later implementation loop can validate it with bounded `Unit` plus `Feature` coverage and one narrow browser or manual smoke path.
## Open Questions
None blocking safe implementation. V1 deliberately chooses a standalone governance navigation entry and bounded detail launch continuity; broader governance-inbox integration remains a follow-up candidate.

View File

@ -0,0 +1,181 @@
---
description: "Task list for Decision Register & Approval Workflow v1"
---
# Tasks: Decision Register & Approval Workflow v1
**Input**: Design documents from `specs/265-decision-register-approval/`
**Prerequisites**: `specs/265-decision-register-approval/spec.md`, `specs/265-decision-register-approval/plan.md`, `specs/265-decision-register-approval/checklists/requirements.md`
**Tests**: REQUIRED (Pest). Keep proof bounded to `Unit` plus `Feature` coverage for the derived register contract and the existing findings detail flow. The canonical proving suite is `GovernanceDecisionRegisterBuilderTest`, `DecisionRegisterPageTest`, `DecisionRegisterAuthorizationTest`, `FindingExceptionDecisionRegisterNavigationTest`, `FindingExceptionDetailDecisionSummaryTest`, and `FindingExceptionDecisionRegisterBoundariesTest`. One narrow browser smoke on the register-to-detail path is also allowed for implementation-loop verification and is now covered by `Spec265DecisionRegisterSmokeTest`.
**Operations**: No new `OperationRun`, queue family, or notification path is allowed. Any related run link must reuse existing `OperationRunLinks` only when current proof already exposes it.
**RBAC**: Workspace non-members and out-of-scope tenant or record targets remain `404`; in-scope members with no visible decisions remain `403`. Register visibility reuses `Capabilities::FINDING_EXCEPTION_VIEW`; current decision actions remain on the detail surface and keep their existing approval capability checks such as `Capabilities::FINDING_EXCEPTION_APPROVE`.
**Shared Pattern Reuse**: Reuse `CanonicalNavigationContext`, `OperationRunLinks`, `BadgeRenderer`, current exception decision history, current exception detail action surfaces, and current audit-trail semantics. Do not create a generic workflow engine or a second decision store.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed.
**Organization**: Tasks are grouped by user story so the derived register, detail continuity, and bounded decision-truth rules remain independently implementable and testable.
## Test Governance Checklist
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceDecisions/`, `apps/platform/tests/Feature/Governance/`, and `apps/platform/tests/Feature/Findings/` families only.
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add new provider setup, queue scaffolding, or a dedicated browser family.
- [x] Planned validation commands cover register assembly, authorization, and launch continuity without widening into unrelated lanes.
- [x] The declared surface test profile remains `global-context-shell` because tenant and return-context continuity are part of the contract.
- [x] The implementation must pass `docs/product/standards/list-surface-review-checklist.md`, and any deviation must be explicit instead of hidden in bespoke row markup.
- [x] Decision-surface proof must cover one dominant next action, diagnostics-secondary treatment, and no duplicate visible decision summary between register and detail.
- [x] Any drift toward a new decision table, workflow engine, cross-family register, or inline register mutation resolves as `reject-or-split`, not hidden implementation growth.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded slice, the current decision truth, and the reviewer stop conditions before implementation begins.
- [x] T001 Review `specs/265-decision-register-approval/spec.md`, `specs/265-decision-register-approval/plan.md`, `specs/265-decision-register-approval/checklists/requirements.md`, `specs/154-finding-risk-acceptance/spec.md`, `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/standards/list-surface-review-checklist.md` together so the slice stays on current decision truth and native list-surface rules.
- [x] T002 [P] Confirm the current exception-decision domain seams in `apps/platform/app/Models/FindingException.php`, `apps/platform/app/Models/FindingExceptionDecision.php`, `apps/platform/app/Models/FindingExceptionEvidenceReference.php`, and `apps/platform/app/Services/Findings/FindingExceptionService.php`.
- [x] T003 [P] Confirm the current operator surfaces in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`.
- [x] T004 [P] Confirm the current shared helper seams in `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/OperationRunLinks.php`, and current finding-exception badge or audit helpers before adding any register-local builder logic.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock the bounded register contract before surface-level implementation begins.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T005 [P] Add failing unit coverage in `apps/platform/tests/Unit/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilderTest.php` for open versus recently-closed row derivation, the 30-calendar-day terminal window, owner and due context, hidden-tenant omission, and honest proof-link availability.
- [x] T006 [P] Add failing feature coverage in `apps/platform/tests/Feature/Governance/DecisionRegisterPageTest.php` and `apps/platform/tests/Feature/Governance/DecisionRegisterAuthorizationTest.php` for page visibility, tenant and register-state filters, truthful filtered empty states after authorized filtering, clearing a tenant prefilter back to the workspace-wide open register, and `404` versus default-unfiltered `403` behavior.
- [x] T007 [P] Add failing feature coverage in `apps/platform/tests/Feature/Findings/FindingExceptionDecisionRegisterNavigationTest.php` and `apps/platform/tests/Feature/Findings/FindingExceptionDetailDecisionSummaryTest.php` for register-to-detail launch continuity, preserved tenant and return context, and the absence of inline mutation controls on the register page.
- [x] T008 Implement a bounded register builder in `apps/platform/app/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilder.php` that derives rows from current `FindingException`, `FindingExceptionDecision`, finding impact, owner, due or review dates, closure reason for recently closed rows, evidence references, and existing proof links only.
- [x] T009 Implement the new native Filament page class `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php` with a Filament-native table or list surface, query-backed tenant and register-state filters, capability-safe omission, and no inline approval workflow.
- [x] T010 [P] Implement `apps/platform/resources/views/filament/pages/governance/decision-register.blade.php` only as a thin host for the native Filament surface, with one dominant `Open decision` action per row, closure reason on recently closed rows, diagnostics-secondary treatment, no duplicate visible decision summary, truthful proof availability cues, and calm empty-state copy.
**Checkpoint**: The derived row contract, visibility rules, and read-only register surface are settled before user-story-specific changes widen.
---
## Phase 3: User Story 1 - See Open Governance Decisions In One Register (Priority: P1)
**Goal**: Give the operator one bounded register that surfaces open and recently closed accepted-risk governance decisions from existing truth.
**Independent Test**: Seed visible and hidden tenants with pending, expiring, lapsed, and recently closed decisions, open the register, and verify that only visible-tenant rows appear with owner, due context, status, impact, and one dominant next action.
### Tests for User Story 1
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/DecisionRegisterPageTest.php` to cover pending approval, pending renewal, active or expiring follow-up, lapsed governance, recently closed rows inside the 30-calendar-day window with closure reason visible, rows older than that window being excluded, and hidden-tenant omission.
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/Governance/DecisionRegisterAuthorizationTest.php` to cover `403` for in-scope members with no visible rows and `404` for explicit out-of-scope tenant filters or detail targets.
### Implementation for User Story 1
- [x] T013 [US1] Register the new page inside the existing governance navigation in `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php` so it becomes one bounded workspace decision surface without becoming a new searchable resource or a second inbox.
- [x] T014 [US1] Reuse current badge and vocabulary semantics in `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php`, `apps/platform/resources/views/filament/pages/governance/decision-register.blade.php`, and localization files only as needed so row state, due context, closure reason, and next-action wording stay aligned with current exception surfaces.
**Checkpoint**: User Story 1 is independently functional when the register truthfully surfaces the current decisions that need follow-through and keeps hidden tenants silent.
---
## Phase 4: User Story 2 - Open The Existing Approval Surface With Context (Priority: P1)
**Goal**: Preserve tenant and register context when the operator opens the current exception detail and action surface from the new register.
**Independent Test**: Open a decision row from the register, land on the current exception detail page, verify the existing approval or closure actions remain there, and return to the same register scope.
### Tests for User Story 2
- [x] T015 [P] [US2] Extend `apps/platform/tests/Feature/Findings/FindingExceptionDecisionRegisterNavigationTest.php` for register-to-detail launch continuity, preserved tenant scope, truthful return-path handling, and tenant-filter reset back to the workspace-wide open register when the actor clears the prefilter.
- [x] T016 [P] [US2] Extend `apps/platform/tests/Feature/Findings/FindingExceptionDetailDecisionSummaryTest.php` to cover register-aware decision summary context on the detail page and confirm that current approve, reject, renew, or revoke actions remain the only mutation path.
### Implementation for User Story 2
- [x] T017 [US2] Wire `CanonicalNavigationContext` through `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php` so the detail page can return to the correct register scope and clearing a tenant prefilter restores the workspace-wide open register.
- [x] T018 [US2] Add any bounded detail-summary or back-link affordance needed in `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php` and its supporting view or infolist seams so the detail page deepens the chosen decision, keeps diagnostics secondary, and avoids duplicating the workspace summary.
**Checkpoint**: User Story 2 is independently functional when the register chooses the current record and the current detail surface remains the only action owner.
---
## Phase 5: User Story 3 - Keep Decision Truth Audit-Safe And Bounded (Priority: P2)
**Goal**: Ensure the register stays derived from the current decision history, audit trail, and evidence references without creating a second workflow state.
**Independent Test**: Verify that register rows derive from current decision history, recently closed rows remain bounded and show closure reason, and missing proof stays truthful without generating anything new.
### Tests for User Story 3
- [x] T019 [P] [US3] Extend `apps/platform/tests/Unit/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilderTest.php` to assert that current row state, the 30-calendar-day terminal-window eligibility, closure reason, and next action remain derived from append-only decision history only.
- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/FindingExceptionDecisionRegisterBoundariesTest.php` to prove that missing review or run proof does not trigger generation, hidden proof stays omitted, the 30-calendar-day recently-closed boundary is enforced, and no second register mutation path appears.
### Implementation for User Story 3
- [x] T021 [US3] Reuse current `FindingExceptionDecision`, `AuditLog`, and evidence-reference truth in `apps/platform/app/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilder.php` and any supporting detail-surface code instead of adding a second decision summary store.
- [x] T022 [US3] Expose related evidence and optional `OperationRun` links through existing helpers only when current proof exists, and render truthful absence when it does not.
- [x] T023 [US3] Review the implementation to confirm it introduces no new decision table, no generic workflow engine, no inline register mutation lane, no new run type, no new global search contract, no new panel or asset strategy, one dominant next action per row, diagnostics-secondary treatment, and no duplicate visible decision summary.
**Checkpoint**: User Story 3 is independently functional when the register remains derived, auditable, and bounded.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Validate the bounded slice and stop before wider workflow growth appears.
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilderTest.php`.
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/DecisionRegisterPageTest.php tests/Feature/Governance/DecisionRegisterAuthorizationTest.php tests/Feature/Findings/FindingExceptionDecisionRegisterNavigationTest.php tests/Feature/Findings/FindingExceptionDetailDecisionSummaryTest.php tests/Feature/Findings/FindingExceptionDecisionRegisterBoundariesTest.php`.
- [x] T026 [P] Perform one narrow browser or manual smoke on the register flow: open `/admin/governance/decisions`, apply a tenant filter, open a decision, confirm the existing detail actions, and return to the same register scope.
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
- [x] T028 [P] Review touched code against `docs/product/standards/list-surface-review-checklist.md` and confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, the register remains a native table or list surface, no globally searchable resource is added, and no new asset strategy appears.
- [x] T029 [P] Review touched code to confirm normal decision actions remain DB-backed and audit-tracked, no new `OperationRun` start path or queued notification family is introduced, and the slice does not widen into alerts, operations, or customer-facing decision workflows.
---
## 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 register itself.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the new register is not a dead-end summary.
- **Phase 5 (US3)**: depends on Phase 2 and hardens the bounded-truth rules after the register contract exists.
- **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 register value.
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the register remains a truthful decision hub.
- **US3 (P2)**: independently testable after Phase 2 and protects the bounded architecture.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap.
- Land the shared builder and page contract before widening navigation or detail-surface changes.
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. The slice becomes meaningful only when the register exists and can open the current action-owning detail surface with preserved context.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and US2 together.
3. Add US3 boundary hardening.
4. Finish with the focused validation and smoke path in Phase 6.
### Team Strategy
1. Settle the derived register builder and page contract first.
2. Parallelize unit and feature coverage inside each story before runtime edits widen.
3. Serialize merges around `DecisionRegister`, `ViewFindingException`, and any shared navigation helper so the launch and return language stays coherent.
---
## Deferred Follow-Ups / Non-Goals
- governance inbox launch integration
- alerts, operations, and review follow-up as additional decision families
- customer-facing decision awareness or delivery-pack follow-through
- generic escalation hooks, assignment engines, or autonomous routing