Compare commits
2 Commits
219-findin
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bd06b479e1 | |||
| c86b399b43 |
23
.github/agents/copilot-instructions.md
vendored
23
.github/agents/copilot-instructions.md
vendored
@ -220,6 +220,10 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
|
||||
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
|
||||
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
|
||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
|
||||
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders (220-governance-run-summaries)
|
||||
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -254,11 +258,20 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
|
||||
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
|
||||
- 216-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
|
||||
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
|
||||
- 214-governance-outcome-compression: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `OperatorExplanationBuilder`, `BaselineSnapshotPresenter`, `BadgeCatalog`, `BadgeRenderer`, existing governance Filament resources/pages, and current Enterprise Detail builders
|
||||
- 213-website-foundation-v0: Added Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests
|
||||
- 214-website-visual-foundation: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro component primitives, Playwright browser smoke tests
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
Before adding aliases, fallback readers, dual-write logic, migration shims, or legacy fixtures, verify all of the following:
|
||||
|
||||
1. Do live production data exist?
|
||||
2. Is shared staging migration-relevant?
|
||||
3. Does an external contract depend on the old shape?
|
||||
4. Does the spec explicitly require compatibility behavior?
|
||||
|
||||
If all answers are no, replace the old shape and remove the compatibility path.
|
||||
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.5.0 -> 2.6.0
|
||||
- Modified principles:
|
||||
- UI surface taxonomy and review expectations: expanded with native
|
||||
vs custom classification, shared-detail host ownership, named
|
||||
anti-patterns, and shell/page/detail state ownership review
|
||||
- Filament Native First / No Ad-hoc Styling (UI-FIL-001): expanded
|
||||
into explicit native-by-default, fake-native, shared-family, and
|
||||
exception-boundary language
|
||||
- Added sections: None
|
||||
- Version change: 2.6.0 -> 2.7.0
|
||||
- Modified principles: None
|
||||
- Added sections:
|
||||
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
||||
migration shims, dual-write logic, and compatibility fixtures in a
|
||||
pre-production codebase; includes AI-agent verification checklist,
|
||||
review rule, and explicit exit condition at first production deploy
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- None in this docs-only constitution slice; enforcement remains
|
||||
deferred to Spec 201
|
||||
- .specify/templates/spec-template.md: added "Compatibility posture"
|
||||
default block ✅
|
||||
- .github/agents/copilot-instructions.md: added "Pre-production
|
||||
compatibility check" agent checklist ✅
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||
- Follow-up TODOs: None
|
||||
-->
|
||||
|
||||
@ -133,6 +133,37 @@ ### Spec Candidate Gate (SPEC-GATE-001)
|
||||
### Default Bias (BIAS-001)
|
||||
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||
|
||||
### Pre-Production Lean Doctrine (LEAN-001)
|
||||
|
||||
This product has no production deployment, no live customer data, no shared staging with migration-relevant state, and no external API contract consumers.
|
||||
|
||||
#### Data and schema
|
||||
- Old data shapes, column names, enum values, and operation types MAY be replaced in place.
|
||||
- Migration shims, dual-write logic, and fallback readers MUST NOT be created unless a spec explicitly requires compatibility behavior.
|
||||
|
||||
#### Terminology and types
|
||||
- Renamed or unified operation types, reason codes, and status values MUST replace the old value everywhere (code, config, tests, fixtures, seed data).
|
||||
- Legacy aliases kept "just in case" are forbidden.
|
||||
|
||||
#### Codebase hygiene
|
||||
- Dead constants, dead enum cases, orphan config keys, and test fixtures that reference replaced shapes MUST be removed in the same PR that introduces the replacement.
|
||||
- "Old runs / old rows don't matter" is the standing assumption until the product ships.
|
||||
|
||||
#### AI-agent rule
|
||||
- Before adding aliases, fallback readers, dual-write logic, migration shims, or legacy fixtures, agents MUST verify:
|
||||
1. Do live production data exist?
|
||||
2. Is shared staging migration-relevant?
|
||||
3. Does an external contract depend on the old shape?
|
||||
4. Does the spec explicitly require compatibility behavior?
|
||||
- If all answers are no, replace the old shape and remove the compatibility path.
|
||||
|
||||
#### Review rule
|
||||
- Any PR that introduces a new legacy alias, compatibility shim, or historical fixture without answering the four questions above is a merge blocker.
|
||||
|
||||
#### Exit condition
|
||||
- LEAN-001 expires when the first production deployment occurs.
|
||||
- At that point, the constitution MUST be amended to define the real migration and compatibility policy.
|
||||
|
||||
### Workspace Isolation is Non-negotiable
|
||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||
deny-as-not-found (404).
|
||||
@ -1573,4 +1604,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18
|
||||
**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19
|
||||
|
||||
@ -101,6 +101,14 @@ ## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||
- **Release truth**: [Current-release truth or future-release preparation]
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name.
|
||||
|
||||
@ -246,21 +246,10 @@ public function blockedExecutionBanner(): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||
? array_values(array_filter([
|
||||
$operatorExplanation->headline,
|
||||
$operatorExplanation->dominantCauseExplanation,
|
||||
]))
|
||||
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
||||
$this->surfaceFailureDetail() ?? 'The queued operation was refused before side effects could begin.',
|
||||
]);
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Blocked by prerequisite',
|
||||
'body' => implode(' ', array_values(array_unique($lines))),
|
||||
'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -137,7 +137,7 @@ public function table(Table $table): Table
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
type: 'backup_set.update',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
@ -220,7 +220,7 @@ public function table(Table $table): Table
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.remove_policies',
|
||||
type: 'backup_set.update',
|
||||
inputs: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'backup_item_ids' => $backupItemIds,
|
||||
|
||||
@ -177,12 +177,11 @@ public static function infolist(Schema $schema): Schema
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null),
|
||||
TextEntry::make('owner_user_id_leading')
|
||||
->label('Owner')
|
||||
->state(fn (Finding $record): string => $record->ownerUser?->name ?? 'Unassigned'),
|
||||
TextEntry::make('assignee_user_id_leading')
|
||||
->label('Assignee')
|
||||
->state(fn (Finding $record): string => $record->assigneeUser?->name ?? 'Unassigned'),
|
||||
TextEntry::make('finding_responsibility_state_leading')
|
||||
->label('Responsibility state')
|
||||
->badge()
|
||||
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
|
||||
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
|
||||
TextEntry::make('finding_primary_narrative')
|
||||
->label('Current reading')
|
||||
->state(fn (Finding $record): string => static::primaryNarrative($record))
|
||||
@ -207,6 +206,27 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Responsibility')
|
||||
->schema([
|
||||
TextEntry::make('finding_responsibility_state')
|
||||
->label('Responsibility state')
|
||||
->badge()
|
||||
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
|
||||
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
|
||||
TextEntry::make('owner_user_id_leading')
|
||||
->label('Accountable owner')
|
||||
->state(fn (Finding $record): string => static::accountableOwnerDisplay($record)),
|
||||
TextEntry::make('assignee_user_id_leading')
|
||||
->label('Active assignee')
|
||||
->state(fn (Finding $record): string => static::activeAssigneeDisplay($record)),
|
||||
TextEntry::make('finding_responsibility_summary')
|
||||
->label('Current split')
|
||||
->state(fn (Finding $record): string => static::responsibilitySummary($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Finding')
|
||||
->schema([
|
||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||
@ -268,12 +288,6 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
|
||||
TextEntry::make('sla_days')->label('SLA days')->placeholder('—'),
|
||||
TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||
TextEntry::make('assignee_user_id')
|
||||
->label('Assignee')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||
TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
|
||||
@ -722,7 +736,13 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->placeholder('—')
|
||||
->description(fn (Finding $record): ?string => static::governanceWarning($record)),
|
||||
->description(fn (Finding $record): ?string => static::governanceListDescription($record)),
|
||||
Tables\Columns\TextColumn::make('responsibility_state')
|
||||
->label('Responsibility')
|
||||
->badge()
|
||||
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
|
||||
->color(fn (Finding $record): string => static::responsibilityStateColor($record))
|
||||
->description(fn (Finding $record): string => static::responsibilitySummary($record)),
|
||||
Tables\Columns\TextColumn::make('evidence_fidelity')
|
||||
->label('Fidelity')
|
||||
->badge()
|
||||
@ -745,10 +765,12 @@ public static function table(Table $table): Table
|
||||
->sortable()
|
||||
->placeholder('—')
|
||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
|
||||
Tables\Columns\TextColumn::make('ownerUser.name')
|
||||
->label('Accountable owner')
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('assigneeUser.name')
|
||||
->label('Assignee')
|
||||
->placeholder('—')
|
||||
->description(fn (Finding $record): string => $record->ownerUser?->name !== null ? 'Owner: '.$record->ownerUser->name : 'Owner: unassigned'),
|
||||
->label('Active assignee')
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||
@ -770,7 +792,7 @@ public static function table(Table $table): Table
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
])),
|
||||
Tables\Filters\Filter::make('my_assigned')
|
||||
->label('My assigned')
|
||||
->label('My assigned work')
|
||||
->query(function (Builder $query): Builder {
|
||||
$userId = auth()->id();
|
||||
|
||||
@ -780,6 +802,17 @@ public static function table(Table $table): Table
|
||||
|
||||
return $query->where('assignee_user_id', (int) $userId);
|
||||
}),
|
||||
Tables\Filters\Filter::make('my_accountability')
|
||||
->label('My accountability')
|
||||
->query(function (Builder $query): Builder {
|
||||
$userId = auth()->id();
|
||||
|
||||
if (! is_numeric($userId)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->where('owner_user_id', (int) $userId);
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingStatuses())
|
||||
->label('Status'),
|
||||
@ -966,13 +999,15 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('assignee_user_id')
|
||||
->label('Assignee')
|
||||
->label('Active assignee')
|
||||
->placeholder('Unassigned')
|
||||
->helperText('Assign the person currently expected to perform or coordinate the remediation work.')
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->label('Accountable owner')
|
||||
->placeholder('Unassigned')
|
||||
->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.')
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
])
|
||||
@ -990,6 +1025,7 @@ public static function table(Table $table): Table
|
||||
$assignedCount = 0;
|
||||
$skippedCount = 0;
|
||||
$failedCount = 0;
|
||||
$classificationCounts = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (! $record instanceof Finding) {
|
||||
@ -1012,14 +1048,25 @@ public static function table(Table $table): Table
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$classification = $workflow->responsibilityChangeClassification(
|
||||
beforeOwnerUserId: is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null,
|
||||
beforeAssigneeUserId: is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null,
|
||||
afterOwnerUserId: $ownerUserId,
|
||||
afterAssigneeUserId: $assigneeUserId,
|
||||
);
|
||||
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
||||
$assignedCount++;
|
||||
$classificationCounts[$classification ?? 'unchanged'] = ($classificationCounts[$classification ?? 'unchanged'] ?? 0) + 1;
|
||||
} catch (Throwable) {
|
||||
$failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.';
|
||||
$classificationSummary = static::bulkResponsibilityClassificationSummary($classificationCounts);
|
||||
if ($classificationSummary !== null) {
|
||||
$body .= ' '.$classificationSummary;
|
||||
}
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
@ -1373,28 +1420,20 @@ public static function assignAction(): Actions\Action
|
||||
])
|
||||
->form([
|
||||
Select::make('assignee_user_id')
|
||||
->label('Assignee')
|
||||
->label('Active assignee')
|
||||
->placeholder('Unassigned')
|
||||
->helperText('Assign the person currently expected to perform or coordinate the remediation work.')
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->label('Accountable owner')
|
||||
->placeholder('Unassigned')
|
||||
->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.')
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding assignment updated',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null,
|
||||
is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null,
|
||||
),
|
||||
);
|
||||
static::runResponsibilityMutation($record, $data, $workflow);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
@ -1488,8 +1527,9 @@ public static function requestExceptionAction(): Actions\Action
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->label('Exception owner')
|
||||
->required()
|
||||
->helperText('Owns the exception record, not the finding outcome.')
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Textarea::make('request_reason')
|
||||
@ -1556,8 +1596,9 @@ public static function renewExceptionAction(): Actions\Action
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->label('Exception owner')
|
||||
->required()
|
||||
->helperText('Owns the exception record, not the finding outcome.')
|
||||
->options(fn (): array => static::tenantMemberOptions())
|
||||
->searchable(),
|
||||
Textarea::make('request_reason')
|
||||
@ -1727,6 +1768,76 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function runResponsibilityMutation(Finding $record, array $data, FindingWorkflowService $workflow): void
|
||||
{
|
||||
$pageRecord = $record;
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different tenant')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->workspace_id !== (int) $tenant->workspace_id) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different workspace')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$beforeOwnerUserId = is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null;
|
||||
$beforeAssigneeUserId = is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null;
|
||||
$assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null;
|
||||
$ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null;
|
||||
|
||||
try {
|
||||
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
||||
|
||||
$pageRecord->refresh();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
Notification::make()
|
||||
->title('Responsibility update failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$classification = $workflow->responsibilityChangeClassification(
|
||||
beforeOwnerUserId: $beforeOwnerUserId,
|
||||
beforeAssigneeUserId: $beforeAssigneeUserId,
|
||||
afterOwnerUserId: $ownerUserId,
|
||||
afterAssigneeUserId: $assigneeUserId,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title($classification === null ? 'Finding responsibility unchanged' : 'Finding responsibility updated')
|
||||
->body($workflow->responsibilityChangeSummary(
|
||||
beforeOwnerUserId: $beforeOwnerUserId,
|
||||
beforeAssigneeUserId: $beforeAssigneeUserId,
|
||||
afterOwnerUserId: $ownerUserId,
|
||||
afterAssigneeUserId: $assigneeUserId,
|
||||
))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
@ -1754,6 +1865,7 @@ private static function runExceptionRequestMutation(Finding $record, array $data
|
||||
|
||||
Notification::make()
|
||||
->title('Exception request submitted')
|
||||
->body('Exception ownership stays separate from the finding owner.')
|
||||
->success()
|
||||
->actions([
|
||||
Actions\Action::make('view_exception')
|
||||
@ -1789,6 +1901,7 @@ private static function runExceptionRenewalMutation(Finding $record, array $data
|
||||
|
||||
Notification::make()
|
||||
->title('Renewal request submitted')
|
||||
->body('Exception ownership stays separate from the finding owner.')
|
||||
->success()
|
||||
->actions([
|
||||
Actions\Action::make('view_exception')
|
||||
@ -1913,6 +2026,87 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex
|
||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $classificationCounts
|
||||
*/
|
||||
private static function bulkResponsibilityClassificationSummary(array $classificationCounts): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($classificationCounts as $classification => $count) {
|
||||
$parts[] = static::responsibilityClassificationLabel($classification).': '.$count;
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('. ', $parts).'.';
|
||||
}
|
||||
|
||||
private static function responsibilityClassificationLabel(string $classification): string
|
||||
{
|
||||
return match ($classification) {
|
||||
'owner_only' => 'Owner only',
|
||||
'assignee_only' => 'Assignee only',
|
||||
'owner_and_assignee' => 'Owner and assignee',
|
||||
'clear_owner' => 'Cleared owner',
|
||||
'clear_assignee' => 'Cleared assignee',
|
||||
default => 'Unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
private static function responsibilityStateColor(Finding $finding): string
|
||||
{
|
||||
return match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'danger',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'warning',
|
||||
default => 'success',
|
||||
};
|
||||
}
|
||||
|
||||
private static function accountableOwnerDisplay(Finding $finding): string
|
||||
{
|
||||
return $finding->ownerUser?->name ?? 'Unassigned';
|
||||
}
|
||||
|
||||
private static function activeAssigneeDisplay(Finding $finding): string
|
||||
{
|
||||
return $finding->assigneeUser?->name ?? 'Unassigned';
|
||||
}
|
||||
|
||||
private static function responsibilitySummary(Finding $finding): string
|
||||
{
|
||||
$ownerName = $finding->ownerUser?->name;
|
||||
$assigneeName = $finding->assigneeUser?->name;
|
||||
|
||||
return match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => $assigneeName !== null
|
||||
? "No accountable owner is set. {$assigneeName} is currently carrying the active remediation work."
|
||||
: 'No accountable owner or active assignee is set.',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => "{$ownerName} owns the outcome, but active remediation is still unassigned.",
|
||||
default => $ownerName === $assigneeName
|
||||
? "{$ownerName} owns the outcome and is also the active assignee."
|
||||
: "{$ownerName} owns the outcome. {$assigneeName} is the active assignee.",
|
||||
};
|
||||
}
|
||||
|
||||
private static function governanceListDescription(Finding $finding): ?string
|
||||
{
|
||||
$parts = array_values(array_filter([
|
||||
static::governanceWarning($finding),
|
||||
static::resolvedFindingException($finding)?->owner?->name !== null
|
||||
? 'Exception owner: '.static::resolvedFindingException($finding)?->owner?->name
|
||||
: null,
|
||||
]));
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private static function governanceWarning(Finding $finding): ?string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
|
||||
@ -280,16 +280,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
: null;
|
||||
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||
$diagnosticSummary = OperationUxPresenter::governanceDiagnosticSummary($record);
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||
$decisionNextStep = $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
|
||||
? [
|
||||
'text' => $diagnosticSummary->nextActionText,
|
||||
'source' => $diagnosticSummary->nextActionCategory,
|
||||
'secondaryGuidance' => $primaryNextStep['secondaryGuidance'],
|
||||
]
|
||||
: $primaryNextStep;
|
||||
$restoreContinuation = static::restoreContinuation($record);
|
||||
$supportingGroups = static::supportingGroups(
|
||||
record: $record,
|
||||
factory: $factory,
|
||||
referencedTenantLifecycle: $referencedTenantLifecycle,
|
||||
diagnosticSummary: $diagnosticSummary,
|
||||
operatorExplanation: $operatorExplanation,
|
||||
reasonEnvelope: $reasonEnvelope,
|
||||
primaryNextStep: $primaryNextStep,
|
||||
primaryNextStep: $decisionNextStep,
|
||||
);
|
||||
|
||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
@ -307,49 +316,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
|
||||
))
|
||||
->decisionZone($factory->decisionZone(
|
||||
facts: array_values(array_filter([
|
||||
$factory->keyFact(
|
||||
'Execution state',
|
||||
$statusSpec->label,
|
||||
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
facts: static::decisionFacts(
|
||||
factory: $factory,
|
||||
record: $record,
|
||||
statusSpec: $statusSpec,
|
||||
outcomeSpec: $outcomeSpec,
|
||||
artifactTruth: $artifactTruth,
|
||||
operatorExplanation: $operatorExplanation,
|
||||
restoreContinuation: $restoreContinuation,
|
||||
diagnosticSummary: $diagnosticSummary,
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Outcome',
|
||||
$outcomeSpec->label,
|
||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
),
|
||||
static::artifactTruthFact($factory, $artifactTruth),
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result meaning',
|
||||
$operatorExplanation->evaluationResultLabel(),
|
||||
$operatorExplanation->headline,
|
||||
)
|
||||
: null,
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result trust',
|
||||
$operatorExplanation->trustworthinessLabel(),
|
||||
static::detailHintUnlessDuplicate(
|
||||
$operatorExplanation->reliabilityStatement,
|
||||
$artifactTruth?->primaryExplanation,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
is_array($restoreContinuation)
|
||||
? $factory->keyFact(
|
||||
'Restore continuation',
|
||||
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
|
||||
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
|
||||
)
|
||||
: null,
|
||||
])),
|
||||
primaryNextStep: $factory->primaryNextStep(
|
||||
$primaryNextStep['text'],
|
||||
$primaryNextStep['source'],
|
||||
$primaryNextStep['secondaryGuidance'],
|
||||
$decisionNextStep['text'],
|
||||
$decisionNextStep['source'],
|
||||
$decisionNextStep['secondaryGuidance'],
|
||||
'Primary next step',
|
||||
),
|
||||
description: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
||||
description: $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
|
||||
? 'Start here to see what happened, how reliable the resulting artifact is, what was affected, and the one next step.'
|
||||
: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
||||
compactCounts: $summaryLine !== null
|
||||
? $factory->countPresentation(summaryLine: $summaryLine)
|
||||
: null,
|
||||
@ -550,6 +535,7 @@ private static function supportingGroups(
|
||||
OperationRun $record,
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
||||
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
array $primaryNextStep,
|
||||
@ -559,6 +545,21 @@ private static function supportingGroups(
|
||||
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
|
||||
|
||||
$guidanceItems = array_values(array_filter([
|
||||
...array_map(
|
||||
static fn (array $fact): array => $factory->keyFact(
|
||||
(string) ($fact['label'] ?? 'Summary detail'),
|
||||
(string) ($fact['value'] ?? '—'),
|
||||
is_string($fact['hint'] ?? null) ? $fact['hint'] : null,
|
||||
tone: match ($fact['emphasis'] ?? null) {
|
||||
'blocked' => 'danger',
|
||||
'caution' => 'warning',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
$diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
|
||||
? array_values(array_filter($diagnosticSummary->secondaryFacts, 'is_array'))
|
||||
: [],
|
||||
),
|
||||
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||
: null,
|
||||
@ -811,6 +812,8 @@ private static function guidanceLabel(string $source): string
|
||||
private static function artifactTruthFact(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?string $hintOverride = null,
|
||||
bool $preferOverride = false,
|
||||
): ?array {
|
||||
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
|
||||
return null;
|
||||
@ -823,19 +826,138 @@ private static function artifactTruthFact(
|
||||
$badge = $outcome->primaryBadge;
|
||||
|
||||
return $factory->keyFact(
|
||||
'Outcome',
|
||||
'Artifact impact',
|
||||
$outcome->primaryLabel,
|
||||
$outcome->primaryReason,
|
||||
$preferOverride ? $hintOverride : ($hintOverride ?? $outcome->primaryReason),
|
||||
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function decisionFacts(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
OperationRun $record,
|
||||
\App\Support\Badges\BadgeSpec $statusSpec,
|
||||
\App\Support\Badges\BadgeSpec $outcomeSpec,
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
mixed $restoreContinuation,
|
||||
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
|
||||
): array {
|
||||
if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) {
|
||||
return array_values(array_filter([
|
||||
$factory->keyFact(
|
||||
'Execution state',
|
||||
$statusSpec->label,
|
||||
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Outcome',
|
||||
$outcomeSpec->label,
|
||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
),
|
||||
static::artifactTruthFact($factory, $artifactTruth),
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result meaning',
|
||||
$operatorExplanation->evaluationResultLabel(),
|
||||
$operatorExplanation->headline,
|
||||
)
|
||||
: null,
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result trust',
|
||||
$operatorExplanation->trustworthinessLabel(),
|
||||
static::detailHintUnlessDuplicate(
|
||||
$operatorExplanation->reliabilityStatement,
|
||||
$artifactTruth?->primaryExplanation,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
is_array($restoreContinuation)
|
||||
? $factory->keyFact(
|
||||
'Restore continuation',
|
||||
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
|
||||
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
|
||||
)
|
||||
: null,
|
||||
]));
|
||||
}
|
||||
|
||||
$facts = [
|
||||
$factory->keyFact(
|
||||
'Execution state',
|
||||
$statusSpec->label,
|
||||
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Outcome',
|
||||
$diagnosticSummary->executionOutcomeLabel,
|
||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
),
|
||||
static::artifactTruthFact(
|
||||
$factory,
|
||||
$artifactTruth,
|
||||
static::detailHintUnlessDuplicate(
|
||||
$diagnosticSummary->headline,
|
||||
$artifactTruth?->primaryExplanation,
|
||||
$diagnosticSummary->primaryReason,
|
||||
),
|
||||
true,
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Dominant cause',
|
||||
$diagnosticSummary->dominantCause['label'],
|
||||
$diagnosticSummary->primaryReason,
|
||||
tone: in_array($diagnosticSummary->nextActionCategory, ['refresh_prerequisite_data', 'review_scope_or_ambiguous_matches'], true)
|
||||
? 'warning'
|
||||
: (in_array($diagnosticSummary->nextActionCategory, ['retry_later', 'no_further_action'], true) ? null : 'danger'),
|
||||
),
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
'Result trust',
|
||||
$operatorExplanation->trustworthinessLabel(),
|
||||
static::detailHintUnlessDuplicate(
|
||||
$operatorExplanation->reliabilityStatement,
|
||||
$diagnosticSummary->primaryReason,
|
||||
),
|
||||
tone: match ($operatorExplanation->trustworthinessLevel->value) {
|
||||
'unusable' => 'danger',
|
||||
'diagnostic_only', 'limited_confidence' => 'warning',
|
||||
default => 'success',
|
||||
},
|
||||
)
|
||||
: null,
|
||||
is_array($restoreContinuation)
|
||||
? $factory->keyFact(
|
||||
'Restore continuation',
|
||||
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
|
||||
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
|
||||
)
|
||||
: null,
|
||||
];
|
||||
|
||||
if (is_array($diagnosticSummary->affectedScaleCue)) {
|
||||
$source = str_replace('_', ' ', (string) ($diagnosticSummary->affectedScaleCue['source'] ?? 'recorded detail'));
|
||||
|
||||
$facts[] = $factory->keyFact(
|
||||
(string) ($diagnosticSummary->affectedScaleCue['label'] ?? 'Affected scale'),
|
||||
(string) ($diagnosticSummary->affectedScaleCue['value'] ?? 'Recorded detail is available.'),
|
||||
'Backed by '.$source.'.',
|
||||
);
|
||||
}
|
||||
|
||||
return array_values(array_filter($facts));
|
||||
}
|
||||
|
||||
private static function decisionAttentionNote(OperationRun $record): ?string
|
||||
{
|
||||
return OperationUxPresenter::decisionAttentionNote($record);
|
||||
}
|
||||
|
||||
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
||||
private static function detailHintUnlessDuplicate(?string $hint, ?string ...$duplicates): ?string
|
||||
{
|
||||
$normalizedHint = static::normalizeDetailText($hint);
|
||||
|
||||
@ -843,9 +965,11 @@ private static function detailHintUnlessDuplicate(?string $hint, ?string $duplic
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
|
||||
foreach ($duplicates as $duplicate) {
|
||||
if ($normalizedHint === static::normalizeDetailText($duplicate)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return trim($hint ?? '');
|
||||
}
|
||||
|
||||
@ -31,6 +31,10 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,10 @@ class RemovePoliciesFromBackupSetJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
|
||||
@ -293,7 +293,7 @@ public function table(Table $table): Table
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.add_policies',
|
||||
type: 'backup_set.update',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
|
||||
@ -47,6 +47,12 @@ class Finding extends Model
|
||||
|
||||
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_ASSIGNED = 'assigned';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
@ -246,6 +252,33 @@ public function resolvedSubjectDisplayName(): ?string
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}
|
||||
|
||||
public function responsibilityState(): string
|
||||
{
|
||||
if ($this->owner_user_id === null) {
|
||||
return self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY;
|
||||
}
|
||||
|
||||
if ($this->assignee_user_id === null) {
|
||||
return self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED;
|
||||
}
|
||||
|
||||
return self::RESPONSIBILITY_STATE_ASSIGNED;
|
||||
}
|
||||
|
||||
public function hasAccountabilityGap(): bool
|
||||
{
|
||||
return $this->responsibilityState() === self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY;
|
||||
}
|
||||
|
||||
public function responsibilityStateLabel(): string
|
||||
{
|
||||
return match ($this->responsibilityState()) {
|
||||
self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'orphaned accountability',
|
||||
self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'owned but unassigned',
|
||||
default => 'assigned',
|
||||
};
|
||||
}
|
||||
|
||||
public function scopeWithSubjectDisplayName(Builder $query): Builder
|
||||
{
|
||||
return $query->addSelect([
|
||||
|
||||
@ -651,16 +651,18 @@ private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): v
|
||||
|
||||
private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int
|
||||
{
|
||||
$label = $this->fieldLabel($field);
|
||||
|
||||
if ($userId === null || $userId === '') {
|
||||
if ($required) {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $label));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_numeric($userId) || (int) $userId <= 0) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $label));
|
||||
}
|
||||
|
||||
$resolvedUserId = (int) $userId;
|
||||
@ -671,7 +673,7 @@ private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $
|
||||
->exists();
|
||||
|
||||
if (! $isMember) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $label));
|
||||
}
|
||||
|
||||
return $resolvedUserId;
|
||||
@ -679,18 +681,20 @@ private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $
|
||||
|
||||
private function validatedReason(mixed $reason, string $field): string
|
||||
{
|
||||
$label = $this->fieldLabel($field);
|
||||
|
||||
if (! is_string($reason)) {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $label));
|
||||
}
|
||||
|
||||
$resolved = trim($reason);
|
||||
|
||||
if ($resolved === '') {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $label));
|
||||
}
|
||||
|
||||
if (mb_strlen($resolved) > 2000) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $label));
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
@ -698,10 +702,12 @@ private function validatedReason(mixed $reason, string $field): string
|
||||
|
||||
private function validatedDate(mixed $value, string $field): CarbonImmutable
|
||||
{
|
||||
$label = $this->fieldLabel($field);
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse((string) $value);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $label));
|
||||
}
|
||||
}
|
||||
|
||||
@ -710,7 +716,7 @@ private function validatedFutureDate(mixed $value, string $field): CarbonImmutab
|
||||
$date = $this->validatedDate($value, $field);
|
||||
|
||||
if ($date->lessThanOrEqualTo(CarbonImmutable::now())) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be in the future.', $field));
|
||||
throw new InvalidArgumentException(sprintf('%s must be in the future.', $this->fieldLabel($field)));
|
||||
}
|
||||
|
||||
return $date;
|
||||
@ -735,6 +741,21 @@ private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum,
|
||||
return $expiresAt;
|
||||
}
|
||||
|
||||
private function fieldLabel(string $field): string
|
||||
{
|
||||
return match ($field) {
|
||||
'owner_user_id' => 'Exception owner',
|
||||
'request_reason' => 'Request reason',
|
||||
'review_due_at' => 'Review due at',
|
||||
'approval_reason' => 'Approval reason',
|
||||
'rejection_reason' => 'Rejection reason',
|
||||
'revocation_reason' => 'Revocation reason',
|
||||
'effective_from' => 'Effective from',
|
||||
'expires_at' => 'Expires at',
|
||||
default => str_replace('_', ' ', $field),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* source_type: string,
|
||||
|
||||
@ -223,14 +223,22 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
|
||||
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
default => 'This finding is historical workflow context.',
|
||||
},
|
||||
default => 'This finding is still active workflow work and should be reviewed until it is resolved, closed, or formally governed.',
|
||||
default => match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
|
||||
default => 'This finding is still active workflow work with accountable ownership and an active assignee.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) {
|
||||
return 'Review the overdue finding and update ownership or next workflow step.';
|
||||
return match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'Review the overdue finding, set an accountable owner, and confirm the next workflow step.',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'Review the overdue finding and assign the active remediation work or the next workflow step.',
|
||||
default => 'Review the overdue finding and confirm the next workflow step.',
|
||||
};
|
||||
}
|
||||
|
||||
if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') {
|
||||
@ -249,11 +257,11 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
|
||||
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||
}
|
||||
|
||||
if ($finding->assignee_user_id === null || $finding->owner_user_id === null) {
|
||||
return 'Assign an owner and next workflow step so follow-up does not stall.';
|
||||
}
|
||||
|
||||
return 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.';
|
||||
return match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'Set an accountable owner so follow-up does not stall, even if remediation work is already assigned.',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'An accountable owner is set. Assign the active remediation work or record the next workflow step.',
|
||||
default => 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.',
|
||||
};
|
||||
}
|
||||
|
||||
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
||||
|
||||
@ -110,6 +110,19 @@ public function assign(
|
||||
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
||||
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
||||
|
||||
$changeClassification = $this->responsibilityChangeClassification(
|
||||
beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null,
|
||||
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
|
||||
afterOwnerUserId: $ownerUserId,
|
||||
afterAssigneeUserId: $assigneeUserId,
|
||||
);
|
||||
$changeSummary = $this->responsibilityChangeSummary(
|
||||
beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null,
|
||||
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
|
||||
afterOwnerUserId: $ownerUserId,
|
||||
afterAssigneeUserId: $assigneeUserId,
|
||||
);
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
@ -119,6 +132,8 @@ public function assign(
|
||||
'metadata' => [
|
||||
'assignee_user_id' => $assigneeUserId,
|
||||
'owner_user_id' => $ownerUserId,
|
||||
'responsibility_change_classification' => $changeClassification,
|
||||
'responsibility_change_summary' => $changeSummary,
|
||||
],
|
||||
],
|
||||
mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void {
|
||||
@ -128,6 +143,55 @@ public function assign(
|
||||
);
|
||||
}
|
||||
|
||||
public function responsibilityChangeClassification(
|
||||
?int $beforeOwnerUserId,
|
||||
?int $beforeAssigneeUserId,
|
||||
?int $afterOwnerUserId,
|
||||
?int $afterAssigneeUserId,
|
||||
): ?string {
|
||||
$ownerChanged = $beforeOwnerUserId !== $afterOwnerUserId;
|
||||
$assigneeChanged = $beforeAssigneeUserId !== $afterAssigneeUserId;
|
||||
|
||||
if ($ownerChanged && $assigneeChanged) {
|
||||
return 'owner_and_assignee';
|
||||
}
|
||||
|
||||
if ($ownerChanged) {
|
||||
return $afterOwnerUserId === null
|
||||
? 'clear_owner'
|
||||
: 'owner_only';
|
||||
}
|
||||
|
||||
if ($assigneeChanged) {
|
||||
return $afterAssigneeUserId === null
|
||||
? 'clear_assignee'
|
||||
: 'assignee_only';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function responsibilityChangeSummary(
|
||||
?int $beforeOwnerUserId,
|
||||
?int $beforeAssigneeUserId,
|
||||
?int $afterOwnerUserId,
|
||||
?int $afterAssigneeUserId,
|
||||
): string {
|
||||
return match ($this->responsibilityChangeClassification(
|
||||
beforeOwnerUserId: $beforeOwnerUserId,
|
||||
beforeAssigneeUserId: $beforeAssigneeUserId,
|
||||
afterOwnerUserId: $afterOwnerUserId,
|
||||
afterAssigneeUserId: $afterAssigneeUserId,
|
||||
)) {
|
||||
'owner_only' => 'Updated the accountable owner and kept the active assignee unchanged.',
|
||||
'assignee_only' => 'Updated the active assignee and kept the accountable owner unchanged.',
|
||||
'owner_and_assignee' => 'Updated the accountable owner and the active assignee.',
|
||||
'clear_owner' => 'Cleared the accountable owner and kept the active assignee unchanged.',
|
||||
'clear_assignee' => 'Cleared the active assignee and kept the accountable owner unchanged.',
|
||||
default => 'No responsibility changes were needed.',
|
||||
};
|
||||
}
|
||||
|
||||
public function resolve(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]);
|
||||
|
||||
@ -273,8 +273,7 @@ private static function operationAliases(): array
|
||||
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
|
||||
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
|
||||
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
|
||||
new OperationTypeAlias('backup_set.add_policies', 'backup_set.update', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.remove_policies', 'backup_set.update', 'legacy_alias', true, 'Removal and addition both resolve to the same backup-set update operator meaning.', 'Use backup_set.update for canonical reporting buckets.'),
|
||||
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
|
||||
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
|
||||
|
||||
@ -175,7 +175,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||
if ($run->type === 'backup_set.update') {
|
||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
|
||||
$backupSetId = $context['backup_set_id'] ?? null;
|
||||
|
||||
@ -10,8 +10,7 @@ enum OperationRunType: string
|
||||
case PolicySync = 'policy.sync';
|
||||
case PolicySyncOne = 'policy.sync_one';
|
||||
case DirectoryGroupsSync = 'entra_group_sync';
|
||||
case BackupSetAddPolicies = 'backup_set.add_policies';
|
||||
case BackupSetRemovePolicies = 'backup_set.remove_policies';
|
||||
case BackupSetUpdate = 'backup_set.update';
|
||||
case BackupScheduleExecute = 'backup_schedule_run';
|
||||
case BackupScheduleRetention = 'backup_schedule_retention';
|
||||
case BackupSchedulePurge = 'backup_schedule_purge';
|
||||
@ -36,6 +35,7 @@ public function canonicalCode(): string
|
||||
self::InventorySync => 'inventory.sync',
|
||||
self::PolicySync, self::PolicySyncOne => 'policy.sync',
|
||||
self::DirectoryGroupsSync => 'directory.groups.sync',
|
||||
self::BackupSetUpdate => 'backup_set.update',
|
||||
self::BackupScheduleExecute => 'backup.schedule.execute',
|
||||
self::BackupScheduleRetention => 'backup.schedule.retention',
|
||||
self::BackupSchedulePurge => 'backup.schedule.purge',
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class GovernanceRunDiagnosticSummary
|
||||
{
|
||||
/**
|
||||
* @param array{label: string, value: string, source: string, confidence?: string}|null $affectedScaleCue
|
||||
* @param array{
|
||||
* code: ?string,
|
||||
* label: string,
|
||||
* explanation: string
|
||||
* } $dominantCause
|
||||
* @param list<array{
|
||||
* code: ?string,
|
||||
* label: string,
|
||||
* explanation: string
|
||||
* }> $secondaryCauses
|
||||
* @param list<array{
|
||||
* label: string,
|
||||
* value: string,
|
||||
* hint?: ?string,
|
||||
* emphasis?: string
|
||||
* }> $secondaryFacts
|
||||
*/
|
||||
public function __construct(
|
||||
public string $headline,
|
||||
public string $executionOutcomeLabel,
|
||||
public string $artifactImpactLabel,
|
||||
public string $primaryReason,
|
||||
public ?array $affectedScaleCue,
|
||||
public string $nextActionCategory,
|
||||
public string $nextActionText,
|
||||
public array $dominantCause,
|
||||
public array $secondaryCauses = [],
|
||||
public array $secondaryFacts = [],
|
||||
public bool $diagnosticsAvailable = false,
|
||||
) {
|
||||
foreach ([
|
||||
'headline' => $this->headline,
|
||||
'executionOutcomeLabel' => $this->executionOutcomeLabel,
|
||||
'artifactImpactLabel' => $this->artifactImpactLabel,
|
||||
'primaryReason' => $this->primaryReason,
|
||||
'nextActionCategory' => $this->nextActionCategory,
|
||||
'nextActionText' => $this->nextActionText,
|
||||
] as $field => $value) {
|
||||
if (trim($value) === '') {
|
||||
throw new InvalidArgumentException("Governance run summaries require {$field}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (trim((string) ($this->dominantCause['label'] ?? '')) === '' || trim((string) ($this->dominantCause['explanation'] ?? '')) === '') {
|
||||
throw new InvalidArgumentException('Governance run summaries require a dominant cause label and explanation.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* headline: string,
|
||||
* executionOutcomeLabel: string,
|
||||
* artifactImpactLabel: string,
|
||||
* primaryReason: string,
|
||||
* affectedScaleCue: array{label: string, value: string, source: string, confidence?: string}|null,
|
||||
* nextActionCategory: string,
|
||||
* nextActionText: string,
|
||||
* dominantCause: array{code: ?string, label: string, explanation: string},
|
||||
* secondaryCauses: list<array{code: ?string, label: string, explanation: string}>,
|
||||
* secondaryFacts: list<array{label: string, value: string, hint?: ?string, emphasis?: string}>,
|
||||
* diagnosticsAvailable: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'headline' => $this->headline,
|
||||
'executionOutcomeLabel' => $this->executionOutcomeLabel,
|
||||
'artifactImpactLabel' => $this->artifactImpactLabel,
|
||||
'primaryReason' => $this->primaryReason,
|
||||
'affectedScaleCue' => $this->affectedScaleCue,
|
||||
'nextActionCategory' => $this->nextActionCategory,
|
||||
'nextActionText' => $this->nextActionText,
|
||||
'dominantCause' => $this->dominantCause,
|
||||
'secondaryCauses' => $this->secondaryCauses,
|
||||
'secondaryFacts' => $this->secondaryFacts,
|
||||
'diagnosticsAvailable' => $this->diagnosticsAvailable,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,913 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
|
||||
final class GovernanceRunDiagnosticSummaryBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtifactTruthPresenter $artifactTruthPresenter,
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
) {}
|
||||
|
||||
public function build(
|
||||
OperationRun $run,
|
||||
?ArtifactTruthEnvelope $artifactTruth = null,
|
||||
?OperatorExplanationPattern $operatorExplanation = null,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope = null,
|
||||
): ?GovernanceRunDiagnosticSummary {
|
||||
if (! $run->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run);
|
||||
$operatorExplanation ??= $artifactTruth?->operatorExplanation;
|
||||
$reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
||||
|
||||
if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$canonicalType = OperationCatalog::canonicalCode((string) $run->type);
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
$causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context);
|
||||
$dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope);
|
||||
$secondaryCauses = array_values(array_slice($causeCandidates, 1));
|
||||
$artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation);
|
||||
$headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts);
|
||||
$primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope);
|
||||
$nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context);
|
||||
$nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope);
|
||||
$affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts);
|
||||
$secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText);
|
||||
|
||||
return new GovernanceRunDiagnosticSummary(
|
||||
headline: $headline,
|
||||
executionOutcomeLabel: $this->executionOutcomeLabel($run),
|
||||
artifactImpactLabel: $artifactImpactLabel,
|
||||
primaryReason: $primaryReason,
|
||||
affectedScaleCue: $affectedScaleCue,
|
||||
nextActionCategory: $nextActionCategory,
|
||||
nextActionText: $nextActionText,
|
||||
dominantCause: [
|
||||
'code' => $dominantCause['code'] ?? null,
|
||||
'label' => $dominantCause['label'],
|
||||
'explanation' => $dominantCause['explanation'],
|
||||
],
|
||||
secondaryCauses: array_map(
|
||||
static fn (array $cause): array => [
|
||||
'code' => $cause['code'] ?? null,
|
||||
'label' => $cause['label'],
|
||||
'explanation' => $cause['explanation'],
|
||||
],
|
||||
$secondaryCauses,
|
||||
),
|
||||
secondaryFacts: $secondaryFacts,
|
||||
diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
private function executionOutcomeLabel(OperationRun $run): string
|
||||
{
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome);
|
||||
|
||||
return $spec->label !== 'Unknown'
|
||||
? $spec->label
|
||||
: ucfirst(str_replace('_', ' ', trim((string) $run->outcome)));
|
||||
}
|
||||
|
||||
private function artifactImpactLabel(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') {
|
||||
return $artifactTruth->primaryLabel;
|
||||
}
|
||||
|
||||
if ($operatorExplanation instanceof OperatorExplanationPattern) {
|
||||
return $operatorExplanation->trustworthinessLabel();
|
||||
}
|
||||
|
||||
return 'Result needs review';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function headline(
|
||||
string $canonicalType,
|
||||
OperationRun $run,
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
array $dominantCause,
|
||||
array $context,
|
||||
array $counts,
|
||||
): string {
|
||||
return match ($canonicalType) {
|
||||
'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation),
|
||||
'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation),
|
||||
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation),
|
||||
'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation),
|
||||
'tenant.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation),
|
||||
default => $operatorExplanation?->headline
|
||||
?? $artifactTruth?->primaryExplanation
|
||||
?? 'This governance run needs review before it can be relied on.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function baselineCaptureHeadline(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
array $context,
|
||||
array $counts,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
return 'No baseline was captured because no governed subjects were ready.';
|
||||
}
|
||||
|
||||
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
||||
return 'The baseline capture started, but more evidence still needs to be collected.';
|
||||
}
|
||||
|
||||
if ($gapCount > 0) {
|
||||
return 'The baseline capture finished, but evidence gaps still limit the snapshot.';
|
||||
}
|
||||
|
||||
if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') {
|
||||
return 'The baseline capture finished without a usable snapshot.';
|
||||
}
|
||||
|
||||
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
|
||||
return 'The baseline capture finished without producing a decision-grade snapshot.';
|
||||
}
|
||||
|
||||
return $operatorExplanation?->headline
|
||||
?? $artifactTruth?->primaryExplanation
|
||||
?? 'The baseline capture needs review before it can be used.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function baselineCompareHeadline(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
array $context,
|
||||
array $counts,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
|
||||
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
||||
$resumeToken = data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
|
||||
return 'The compare finished, but ambiguous subject matching limited the result.';
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
|
||||
return 'The compare finished, but a compare strategy failure kept the result incomplete.';
|
||||
}
|
||||
|
||||
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
||||
return 'The compare finished, but evidence capture still needs to resume before the result is complete.';
|
||||
}
|
||||
|
||||
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
|
||||
return 'The compare finished, but no decision-grade result is available yet.';
|
||||
}
|
||||
|
||||
if ($proof === false) {
|
||||
return 'The compare finished, but missing coverage proof suppressed the normal result.';
|
||||
}
|
||||
|
||||
return $operatorExplanation?->headline
|
||||
?? $artifactTruth?->primaryExplanation
|
||||
?? 'The compare needs follow-up before it can be treated as complete.';
|
||||
}
|
||||
|
||||
private function evidenceSnapshotHeadline(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
return match (true) {
|
||||
$artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.',
|
||||
$artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.',
|
||||
$artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.',
|
||||
default => $operatorExplanation?->headline
|
||||
?? $artifactTruth?->primaryExplanation
|
||||
?? 'The evidence snapshot needs review before it is relied on.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
||||
*/
|
||||
private function reviewComposeHeadline(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
array $dominantCause,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
return match (true) {
|
||||
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale'
|
||||
=> 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
|
||||
$artifactTruth?->contentState === 'partial'
|
||||
=> 'The review was generated, but required sections are still incomplete.',
|
||||
$artifactTruth?->freshnessState === 'stale'
|
||||
=> 'The review was generated, but it relies on stale evidence.',
|
||||
default => $operatorExplanation?->headline
|
||||
?? $dominantCause['explanation']
|
||||
?? 'The review needs follow-up before it should guide action.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
||||
*/
|
||||
private function reviewPackHeadline(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
array $dominantCause,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
return match (true) {
|
||||
$artifactTruth?->publicationReadiness === 'blocked'
|
||||
=> 'The pack did not produce a shareable artifact yet.',
|
||||
$artifactTruth?->publicationReadiness === 'internal_only'
|
||||
=> 'The pack finished, but it should stay internal until the source review is refreshed.',
|
||||
default => $operatorExplanation?->headline
|
||||
?? $dominantCause['explanation']
|
||||
?? 'The review pack needs follow-up before it is shared.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{code: ?string, label: string, explanation: string} $dominantCause
|
||||
*/
|
||||
private function primaryReason(
|
||||
array $dominantCause,
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
): string {
|
||||
return $dominantCause['explanation']
|
||||
?? $operatorExplanation?->dominantCauseExplanation
|
||||
?? $reasonEnvelope?->shortExplanation
|
||||
?? $artifactTruth?->primaryExplanation
|
||||
?? $operatorExplanation?->reliabilityStatement
|
||||
?? 'TenantPilot recorded diagnostic detail for this run.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function nextActionCategory(
|
||||
string $canonicalType,
|
||||
OperationRun $run,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
array $context,
|
||||
): string {
|
||||
if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') {
|
||||
return 'retry_later';
|
||||
}
|
||||
|
||||
if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) {
|
||||
$resumeToken = $canonicalType === 'baseline.capture'
|
||||
? data_get($context, 'baseline_capture.resume_token')
|
||||
: data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
||||
return 'resume_capture_or_generation';
|
||||
}
|
||||
}
|
||||
|
||||
$reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? '');
|
||||
|
||||
if (in_array($reasonCode, [
|
||||
BaselineCompareReasonCode::AmbiguousSubjects->value,
|
||||
BaselineCompareReasonCode::UnsupportedSubjects->value,
|
||||
], true)) {
|
||||
return 'review_scope_or_ambiguous_matches';
|
||||
}
|
||||
|
||||
if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) {
|
||||
return 'refresh_prerequisite_data';
|
||||
}
|
||||
|
||||
if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') {
|
||||
return 'no_further_action';
|
||||
}
|
||||
|
||||
if (
|
||||
$reasonEnvelope?->actionability === 'prerequisite_missing'
|
||||
|| in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'tenant.review.compose', 'tenant.review_pack.generate'], true)
|
||||
) {
|
||||
return 'refresh_prerequisite_data';
|
||||
}
|
||||
|
||||
return 'manually_validate';
|
||||
}
|
||||
|
||||
private function nextActionText(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
): string {
|
||||
$text = $operatorExplanation?->nextActionText
|
||||
?? $artifactTruth?->nextStepText()
|
||||
?? $reasonEnvelope?->firstNextStep()?->label
|
||||
?? 'No action needed';
|
||||
|
||||
return trim(rtrim($text, '.')).'.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, int> $counts
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function affectedScaleCue(
|
||||
string $canonicalType,
|
||||
OperationRun $run,
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
array $context,
|
||||
array $counts,
|
||||
): ?array {
|
||||
return match ($canonicalType) {
|
||||
'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts),
|
||||
'baseline.compare' => $this->baselineCompareScaleCue($context, $counts),
|
||||
'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']),
|
||||
'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
|
||||
'tenant.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
|
||||
default => $this->summaryCountsScaleCue($counts),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, int> $counts
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function baselineCaptureScaleCue(array $context, array $counts): ?array
|
||||
{
|
||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||
|
||||
if ($gapCount > 0) {
|
||||
return [
|
||||
'label' => 'Affected subjects',
|
||||
'value' => "{$gapCount} governed subjects still need evidence follow-up.",
|
||||
'source' => 'context',
|
||||
'confidence' => 'exact',
|
||||
];
|
||||
}
|
||||
|
||||
if ($subjectsTotal >= 0) {
|
||||
return [
|
||||
'label' => 'Capture scope',
|
||||
'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.",
|
||||
'source' => 'context',
|
||||
'confidence' => 'exact',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->summaryCountsScaleCue($counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, int> $counts
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function baselineCompareScaleCue(array $context, array $counts): ?array
|
||||
{
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
|
||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total'));
|
||||
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
|
||||
|
||||
if ($gapCount > 0) {
|
||||
return [
|
||||
'label' => 'Affected subjects',
|
||||
'value' => "{$gapCount} governed subjects still have evidence gaps.",
|
||||
'source' => 'context',
|
||||
'confidence' => 'exact',
|
||||
];
|
||||
}
|
||||
|
||||
if ($uncoveredTypes !== []) {
|
||||
$count = count($uncoveredTypes);
|
||||
|
||||
return [
|
||||
'label' => 'Coverage scope',
|
||||
'value' => "{$count} policy types were left without proven compare coverage.",
|
||||
'source' => 'context',
|
||||
'confidence' => 'bounded',
|
||||
];
|
||||
}
|
||||
|
||||
if ($subjectsTotal > 0) {
|
||||
return [
|
||||
'label' => 'Compare scope',
|
||||
'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.",
|
||||
'source' => 'context',
|
||||
'confidence' => 'exact',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->summaryCountsScaleCue($counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
|
||||
{
|
||||
if ($artifactTruth?->contentState === 'partial') {
|
||||
$sections = $this->findCountDescriptor($countDescriptors, 'Sections');
|
||||
|
||||
if ($sections instanceof CountDescriptor) {
|
||||
return [
|
||||
'label' => 'Review sections',
|
||||
'value' => "{$sections->value} sections were recorded and still need review for completeness.",
|
||||
'source' => 'related_artifact_truth',
|
||||
'confidence' => 'best_available',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => 'Review sections',
|
||||
'value' => 'Required review sections are still incomplete.',
|
||||
'source' => 'related_artifact_truth',
|
||||
'confidence' => 'best_available',
|
||||
];
|
||||
}
|
||||
|
||||
if ($artifactTruth?->freshnessState === 'stale') {
|
||||
return [
|
||||
'label' => 'Evidence freshness',
|
||||
'value' => 'The source evidence is stale for at least part of this review.',
|
||||
'source' => 'related_artifact_truth',
|
||||
'confidence' => 'best_available',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
|
||||
{
|
||||
if ($artifactTruth?->publicationReadiness === 'internal_only') {
|
||||
return [
|
||||
'label' => 'Sharing scope',
|
||||
'value' => 'The pack is suitable for internal follow-up only in its current state.',
|
||||
'source' => 'related_artifact_truth',
|
||||
'confidence' => 'best_available',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
* @param list<string> $preferredLabels
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array
|
||||
{
|
||||
foreach ($preferredLabels as $label) {
|
||||
$descriptor = $this->findCountDescriptor($countDescriptors, $label);
|
||||
|
||||
if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $descriptor->label,
|
||||
'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.",
|
||||
'source' => 'related_artifact_truth',
|
||||
'confidence' => 'exact',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
* @return array{label: string, value: string, source: string, confidence?: string}|null
|
||||
*/
|
||||
private function summaryCountsScaleCue(array $counts): ?array
|
||||
{
|
||||
foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) {
|
||||
$value = (int) ($counts[$key] ?? 0);
|
||||
|
||||
if ($value <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => SummaryCountsNormalizer::label($key),
|
||||
'value' => "{$value} recorded in the canonical run counters.",
|
||||
'source' => 'summary_counts',
|
||||
'confidence' => 'exact',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return list<array{rank: int, code: ?string, label: string, explanation: string}>
|
||||
*/
|
||||
private function rankCauseCandidates(
|
||||
string $canonicalType,
|
||||
OperationRun $run,
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
array $context,
|
||||
): array {
|
||||
$candidates = [];
|
||||
|
||||
$this->pushCandidate(
|
||||
$candidates,
|
||||
code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
|
||||
label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel,
|
||||
explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation,
|
||||
rank: $this->reasonRank($reasonEnvelope, $operatorExplanation),
|
||||
);
|
||||
|
||||
match ($canonicalType) {
|
||||
'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context),
|
||||
'baseline.compare' => $this->baselineCompareCandidates($candidates, $context),
|
||||
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation),
|
||||
'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth),
|
||||
'tenant.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth),
|
||||
default => null,
|
||||
};
|
||||
|
||||
usort($candidates, static function (array $left, array $right): int {
|
||||
$rank = ($right['rank'] <=> $left['rank']);
|
||||
|
||||
if ($rank !== 0) {
|
||||
return $rank;
|
||||
}
|
||||
|
||||
return strcmp($left['label'], $right['label']);
|
||||
});
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (array $candidate): array => [
|
||||
'code' => $candidate['code'],
|
||||
'label' => $candidate['label'],
|
||||
'explanation' => $candidate['explanation'],
|
||||
],
|
||||
$candidates,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
||||
*/
|
||||
private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void
|
||||
{
|
||||
$label = is_string($label) ? trim($label) : '';
|
||||
$explanation = is_string($explanation) ? trim($explanation) : '';
|
||||
|
||||
if ($label === '' || $explanation === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'code' => $code,
|
||||
'label' => $label,
|
||||
'explanation' => $explanation,
|
||||
'rank' => $rank,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function baselineCaptureCandidates(array &$candidates, array $context): void
|
||||
{
|
||||
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
|
||||
$resumeToken = data_get($context, 'baseline_capture.resume_token');
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
|
||||
}
|
||||
|
||||
if ($gapCount > 0) {
|
||||
$this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82);
|
||||
}
|
||||
|
||||
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
||||
$this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function baselineCompareCandidates(array &$candidates, array $context): void
|
||||
{
|
||||
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
|
||||
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
|
||||
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
|
||||
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
||||
$resumeToken = data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
|
||||
$this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92);
|
||||
}
|
||||
|
||||
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
|
||||
$this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94);
|
||||
}
|
||||
|
||||
if ($gapCount > 0) {
|
||||
$this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83);
|
||||
}
|
||||
|
||||
if ($proof === false || $uncoveredTypes !== []) {
|
||||
$count = count($uncoveredTypes);
|
||||
$explanation = $count > 0
|
||||
? "{$count} policy types were left without proven compare coverage."
|
||||
: 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.';
|
||||
|
||||
$this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81);
|
||||
}
|
||||
|
||||
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
||||
$this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
||||
* @param array<int, CountDescriptor> $countDescriptors
|
||||
*/
|
||||
private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void
|
||||
{
|
||||
$countDescriptors = $operatorExplanation?->countDescriptors ?? [];
|
||||
$missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions');
|
||||
$stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions');
|
||||
|
||||
if ($missing instanceof CountDescriptor && $missing->value > 0) {
|
||||
$this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88);
|
||||
}
|
||||
|
||||
if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) {
|
||||
$value = $stale instanceof CountDescriptor && $stale->value > 0
|
||||
? "{$stale->value} evidence dimensions are stale and should be refreshed."
|
||||
: 'Part of the evidence basis is stale and should be refreshed before use.';
|
||||
|
||||
$this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
||||
*/
|
||||
private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
|
||||
{
|
||||
if ($artifactTruth?->contentState === 'partial') {
|
||||
$this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90);
|
||||
}
|
||||
|
||||
if ($artifactTruth?->freshnessState === 'stale') {
|
||||
$this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86);
|
||||
}
|
||||
|
||||
if ($artifactTruth?->publicationReadiness === 'blocked') {
|
||||
$this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
|
||||
*/
|
||||
private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
|
||||
{
|
||||
if ($artifactTruth?->publicationReadiness === 'blocked') {
|
||||
$this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94);
|
||||
}
|
||||
|
||||
if ($artifactTruth?->publicationReadiness === 'internal_only') {
|
||||
$this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80);
|
||||
}
|
||||
|
||||
if ($artifactTruth?->freshnessState === 'stale') {
|
||||
$this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84);
|
||||
}
|
||||
|
||||
if ($artifactTruth?->contentState === 'partial') {
|
||||
$this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86);
|
||||
}
|
||||
}
|
||||
|
||||
private function reasonRank(
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): int {
|
||||
if ($reasonEnvelope?->actionability === 'retryable_transient') {
|
||||
return 76;
|
||||
}
|
||||
|
||||
return match ($operatorExplanation?->nextActionCategory) {
|
||||
'fix_prerequisite' => 92,
|
||||
'retry_later' => 76,
|
||||
'none' => 40,
|
||||
default => 85,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: ?string, label: string, explanation: string}> $secondaryCauses
|
||||
* @return list<array{
|
||||
* label: string,
|
||||
* value: string,
|
||||
* hint?: ?string,
|
||||
* emphasis?: string
|
||||
* }>
|
||||
*/
|
||||
private function secondaryFacts(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
array $secondaryCauses,
|
||||
string $nextActionCategory,
|
||||
string $nextActionText,
|
||||
): array {
|
||||
$facts = [];
|
||||
|
||||
if ($operatorExplanation instanceof OperatorExplanationPattern) {
|
||||
$facts[] = [
|
||||
'label' => 'Result trust',
|
||||
'value' => $operatorExplanation->trustworthinessLabel(),
|
||||
'hint' => $this->deduplicateSecondaryFactHint(
|
||||
$operatorExplanation->reliabilityStatement,
|
||||
$operatorExplanation->dominantCauseExplanation,
|
||||
$artifactTruth?->primaryExplanation,
|
||||
),
|
||||
'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value),
|
||||
];
|
||||
|
||||
if ($operatorExplanation->evaluationResultLabel() !== '') {
|
||||
$facts[] = [
|
||||
'label' => 'Result meaning',
|
||||
'value' => $operatorExplanation->evaluationResultLabel(),
|
||||
'hint' => $operatorExplanation->coverageStatement,
|
||||
'emphasis' => 'neutral',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($secondaryCauses !== []) {
|
||||
$facts[] = [
|
||||
'label' => 'Secondary causes',
|
||||
'value' => implode(' · ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)),
|
||||
'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.',
|
||||
'emphasis' => 'caution',
|
||||
];
|
||||
}
|
||||
|
||||
if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') {
|
||||
$facts[] = [
|
||||
'label' => 'Related artifact access',
|
||||
'value' => 'No related artifact link is available from this run.',
|
||||
'emphasis' => 'neutral',
|
||||
];
|
||||
}
|
||||
|
||||
return $facts;
|
||||
}
|
||||
|
||||
private function emphasisFromTrust(string $trust): string
|
||||
{
|
||||
return match ($trust) {
|
||||
'unusable' => 'blocked',
|
||||
'diagnostic_only', 'limited_confidence' => 'caution',
|
||||
default => 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string
|
||||
{
|
||||
$normalizedHint = $this->normalizeFactText($hint);
|
||||
|
||||
if ($normalizedHint === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($duplicates as $duplicate) {
|
||||
if ($normalizedHint === $this->normalizeFactText($duplicate)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return trim($hint ?? '');
|
||||
}
|
||||
|
||||
private function fallbackCause(
|
||||
?ArtifactTruthEnvelope $artifactTruth,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
): array {
|
||||
return [
|
||||
'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
|
||||
'label' => $reasonEnvelope?->operatorLabel
|
||||
?? $operatorExplanation?->dominantCauseLabel
|
||||
?? $artifactTruth?->primaryLabel
|
||||
?? 'Follow-up required',
|
||||
'explanation' => $reasonEnvelope?->shortExplanation
|
||||
?? $operatorExplanation?->dominantCauseExplanation
|
||||
?? $artifactTruth?->primaryExplanation
|
||||
?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.',
|
||||
];
|
||||
}
|
||||
|
||||
private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor
|
||||
{
|
||||
foreach ($countDescriptors as $descriptor) {
|
||||
if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) {
|
||||
return $descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function intValue(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
private function pluralizeDescriptor(CountDescriptor $descriptor): string
|
||||
{
|
||||
return match ($descriptor->label) {
|
||||
'Missing dimensions' => 'evidence dimensions are missing',
|
||||
'Stale dimensions' => 'evidence dimensions are stale',
|
||||
'Evidence dimensions' => 'evidence dimensions were recorded',
|
||||
'Sections' => 'sections were recorded',
|
||||
'Reports' => 'reports were recorded',
|
||||
'Findings' => 'findings were recorded',
|
||||
default => strtolower($descriptor->label).' were recorded',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeFactText(?string $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
|
||||
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtolower($normalized);
|
||||
}
|
||||
}
|
||||
@ -350,6 +350,16 @@ public static function governanceOperatorExplanation(OperationRun $run): ?Operat
|
||||
return self::resolveGovernanceOperatorExplanation($run);
|
||||
}
|
||||
|
||||
public static function governanceDiagnosticSummary(OperationRun $run): ?GovernanceRunDiagnosticSummary
|
||||
{
|
||||
return self::resolveGovernanceDiagnosticSummary($run);
|
||||
}
|
||||
|
||||
public static function governanceDiagnosticSummaryFresh(OperationRun $run): ?GovernanceRunDiagnosticSummary
|
||||
{
|
||||
return self::resolveGovernanceDiagnosticSummary($run, fresh: true);
|
||||
}
|
||||
|
||||
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
|
||||
{
|
||||
return self::resolveGovernanceOperatorExplanation($run, fresh: true);
|
||||
@ -492,6 +502,29 @@ private static function resolveGovernanceOperatorExplanation(OperationRun $run,
|
||||
);
|
||||
}
|
||||
|
||||
private static function resolveGovernanceDiagnosticSummary(OperationRun $run, bool $fresh = false): ?GovernanceRunDiagnosticSummary
|
||||
{
|
||||
if (! $run->supportsOperatorExplanation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::memoizeExplanation(
|
||||
run: $run,
|
||||
variant: 'governance_diagnostic_summary',
|
||||
resolver: fn (): ?GovernanceRunDiagnosticSummary => app(GovernanceRunDiagnosticSummaryBuilder::class)->build(
|
||||
run: $run,
|
||||
artifactTruth: $fresh
|
||||
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)
|
||||
: app(ArtifactTruthPresenter::class)->forOperationRun($run),
|
||||
operatorExplanation: $fresh
|
||||
? self::resolveGovernanceOperatorExplanation($run, fresh: true)
|
||||
: self::resolveGovernanceOperatorExplanation($run),
|
||||
reasonEnvelope: app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
|
||||
),
|
||||
fresh: $fresh,
|
||||
);
|
||||
}
|
||||
|
||||
private static function memoizeGuidance(
|
||||
OperationRun $run,
|
||||
string $variant,
|
||||
|
||||
@ -21,6 +21,7 @@ public function __construct(
|
||||
public ?string $operatorLabel,
|
||||
public ?string $shortExplanation,
|
||||
public ?string $diagnosticCode,
|
||||
public ?string $actionability,
|
||||
public string $trustImpact,
|
||||
public ?string $absencePattern,
|
||||
public array $nextSteps = [],
|
||||
@ -43,6 +44,7 @@ public static function fromReasonResolutionEnvelope(
|
||||
operatorLabel: $reason->operatorLabel,
|
||||
shortExplanation: $reason->shortExplanation,
|
||||
diagnosticCode: $reason->diagnosticCode(),
|
||||
actionability: $reason->actionability,
|
||||
trustImpact: $reason->trustImpact,
|
||||
absencePattern: $reason->absencePattern,
|
||||
nextSteps: array_values(array_map(
|
||||
@ -79,7 +81,8 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
|
||||
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
|
||||
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
|
||||
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing',
|
||||
actionability: $this->actionability
|
||||
?? ($this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing'),
|
||||
nextSteps: array_map(
|
||||
static fn (string $label): NextStepOption => NextStepOption::instruction($label),
|
||||
$this->nextSteps,
|
||||
@ -98,6 +101,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* actionability: ?string,
|
||||
* trustImpact: string,
|
||||
* absencePattern: ?string,
|
||||
* nextSteps: array<int, string>,
|
||||
@ -114,6 +118,7 @@ public function toArray(): array
|
||||
'operatorLabel' => $this->operatorLabel,
|
||||
'shortExplanation' => $this->shortExplanation,
|
||||
'diagnosticCode' => $this->diagnosticCode,
|
||||
'actionability' => $this->actionability,
|
||||
'trustImpact' => $this->trustImpact,
|
||||
'absencePattern' => $this->absencePattern,
|
||||
'nextSteps' => $this->nextSteps,
|
||||
|
||||
@ -76,6 +76,14 @@
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'backup_set.update' => [
|
||||
'job_class' => \App\Jobs\AddPoliciesToBackupSetJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'backup_schedule_run' => [
|
||||
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
|
||||
@ -11,44 +11,6 @@
|
||||
<div
|
||||
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
||||
>
|
||||
<x-filament::section heading="Monitoring detail" class="mb-6">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($contextBanner !== null)
|
||||
@php
|
||||
$bannerClasses = match ($contextBanner['tone']) {
|
||||
@ -117,5 +79,43 @@
|
||||
@endif
|
||||
|
||||
{{ $this->infolist }}
|
||||
|
||||
<x-filament::section heading="Monitoring detail" class="mt-6">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -239,7 +239,7 @@ function spec192ApprovedFindingException(Tenant $tenant, User $requester)
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
@ -15,6 +16,8 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 404 for non-members on the baseline compare explanation surface', function (): void {
|
||||
[$member, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -99,3 +102,65 @@
|
||||
->get(ReviewRegister::getUrl(panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders governance summary facts for entitled viewers on the canonical run detail surface', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'ambiguous_subjects',
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Artifact impact')
|
||||
->assertSee('Dominant cause')
|
||||
->assertSee('Ambiguous matches');
|
||||
});
|
||||
|
||||
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => 'tenant.review_pack.generate',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup_set.add_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
@ -202,7 +202,7 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.remove_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'remove-hash-1',
|
||||
|
||||
@ -50,7 +50,7 @@
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup_set.remove_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -79,6 +79,24 @@ protected function makePartialArtifactTruthEvidenceSnapshot(
|
||||
);
|
||||
}
|
||||
|
||||
protected function makeMissingArtifactTruthEvidenceSnapshot(
|
||||
Tenant $tenant,
|
||||
array $snapshotOverrides = [],
|
||||
array $summaryOverrides = [],
|
||||
): EvidenceSnapshot {
|
||||
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
|
||||
|
||||
return $this->restateArtifactTruthEvidenceSnapshot(
|
||||
$snapshot,
|
||||
EvidenceCompletenessState::Missing,
|
||||
array_replace([
|
||||
'dimension_count' => 0,
|
||||
'missing_dimensions' => 1,
|
||||
'stale_dimensions' => 0,
|
||||
], $summaryOverrides),
|
||||
);
|
||||
}
|
||||
|
||||
protected function makeArtifactTruthReview(
|
||||
Tenant $tenant,
|
||||
User $user,
|
||||
@ -115,6 +133,32 @@ protected function makeArtifactTruthReview(
|
||||
return TenantReview::query()->create(array_replace($defaults, $reviewOverrides));
|
||||
}
|
||||
|
||||
protected function makePartialArtifactTruthReview(
|
||||
Tenant $tenant,
|
||||
User $user,
|
||||
?EvidenceSnapshot $snapshot = null,
|
||||
array $reviewOverrides = [],
|
||||
array $summaryOverrides = [],
|
||||
): TenantReview {
|
||||
return $this->makeArtifactTruthReview(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
snapshot: $snapshot,
|
||||
reviewOverrides: array_replace([
|
||||
'status' => TenantReviewStatus::Ready->value,
|
||||
'completeness_state' => TenantReviewCompletenessState::Partial->value,
|
||||
], $reviewOverrides),
|
||||
summaryOverrides: array_replace_recursive([
|
||||
'section_state_counts' => [
|
||||
'complete' => 4,
|
||||
'partial' => 1,
|
||||
'missing' => 1,
|
||||
'stale' => 0,
|
||||
],
|
||||
], $summaryOverrides),
|
||||
);
|
||||
}
|
||||
|
||||
protected function makeBlockedArtifactTruthReview(
|
||||
Tenant $tenant,
|
||||
User $user,
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup_set.remove_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_set.add_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
expect($run?->outcome)->toBe('pending');
|
||||
expect($run?->context['backup_set_id'] ?? null)->toBe($backupSet->getKey());
|
||||
expect($run?->context['policy_count'] ?? null)->toBe(count($policyIds));
|
||||
expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.add_policies');
|
||||
expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.update');
|
||||
expect($run?->context['selection']['kind'] ?? null)->toBe('ids');
|
||||
expect($run?->context['idempotency']['fingerprint'] ?? null)->not->toBeNull();
|
||||
|
||||
@ -122,13 +122,13 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_set.add_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->count())->toBe(1);
|
||||
|
||||
Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1);
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
$expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
$expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.update');
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle());
|
||||
@ -173,7 +173,7 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_set.add_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -223,12 +223,12 @@
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->where('type', 'backup_set.add_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->exists())->toBeFalse();
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenantB->id)
|
||||
->where('type', 'backup_set.add_policies')
|
||||
->where('type', 'backup_set.update')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -77,7 +77,7 @@ function visibleLivewireText(Testable $component): string
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Execution failed')
|
||||
->assertSee($explanation?->headline ?? '')
|
||||
->assertSee('The baseline capture finished without a usable snapshot.')
|
||||
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertSee('Artifact not usable')
|
||||
@ -136,11 +136,10 @@ function visibleLivewireText(Testable $component): string
|
||||
->assertSee('Result trust')
|
||||
->assertSee('Primary next step')
|
||||
->assertSee('Artifact truth details')
|
||||
->assertSee($explanation?->headline ?? '')
|
||||
->assertSee('The compare finished, but no decision-grade result is available yet.')
|
||||
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertSee($explanation?->nextActionText ?? '')
|
||||
->assertSee('The run completed, but normal output was intentionally suppressed.')
|
||||
->assertSee('Resume or rerun evidence capture before relying on this compare result.')
|
||||
->assertDontSee('Artifact next step');
|
||||
|
||||
@ -206,7 +205,7 @@ function visibleLivewireText(Testable $component): string
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee($explanation?->headline ?? '')
|
||||
->assertSee('The compare finished, but a compare strategy failure kept the result incomplete.')
|
||||
->assertSee($explanation?->nextActionText ?? '')
|
||||
->assertSee('Compare strategy')
|
||||
->assertSee('Intune Policy')
|
||||
@ -314,7 +313,7 @@ function visibleLivewireText(Testable $component): string
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee($explanation?->headline ?? '')
|
||||
->assertSee('The compare finished, but no decision-grade result is available yet.')
|
||||
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertDontSee('No confirmed drift in the latest baseline compare.');
|
||||
|
||||
@ -173,7 +173,7 @@ function baselineCompareGapContext(array $overrides = []): array
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
|
||||
@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Field;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders explicit responsibility states on findings list and detail surfaces', function (): void {
|
||||
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$ownerOnly = tenantFindingUser($tenant, 'Owner Only');
|
||||
$listOwner = tenantFindingUser($tenant, 'List Owner');
|
||||
$listAssignee = tenantFindingUser($tenant, 'List Assignee');
|
||||
$assigneeOnly = tenantFindingUser($tenant, 'Assignee Only');
|
||||
$samePerson = tenantFindingUser($tenant, 'Same Person');
|
||||
|
||||
$ownerOnlyFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => (int) $ownerOnly->getKey(),
|
||||
'assignee_user_id' => null,
|
||||
]);
|
||||
|
||||
$assignedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_TRIAGED,
|
||||
'owner_user_id' => (int) $listOwner->getKey(),
|
||||
'assignee_user_id' => (int) $listAssignee->getKey(),
|
||||
]);
|
||||
|
||||
$assigneeOnlyFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => (int) $assigneeOnly->getKey(),
|
||||
]);
|
||||
|
||||
$bothNullFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
]);
|
||||
|
||||
$sameUserFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => (int) $samePerson->getKey(),
|
||||
'assignee_user_id' => (int) $samePerson->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($viewer);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertCanSeeTableRecords([
|
||||
$ownerOnlyFinding,
|
||||
$assignedFinding,
|
||||
$assigneeOnlyFinding,
|
||||
$bothNullFinding,
|
||||
$sameUserFinding,
|
||||
])
|
||||
->assertSee('Responsibility')
|
||||
->assertSee('Accountable owner')
|
||||
->assertSee('Active assignee')
|
||||
->assertSee('owned but unassigned')
|
||||
->assertSee('assigned')
|
||||
->assertSee('orphaned accountability')
|
||||
->assertSee('Owner Only')
|
||||
->assertSee('List Owner')
|
||||
->assertSee('List Assignee')
|
||||
->assertSee('Assignee Only')
|
||||
->assertSee('Same Person');
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $assigneeOnlyFinding->getKey()])
|
||||
->assertSee('Responsibility state')
|
||||
->assertSee('orphaned accountability')
|
||||
->assertSee('Accountable owner')
|
||||
->assertSee('Active assignee')
|
||||
->assertSee('Assignee Only');
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $sameUserFinding->getKey()])
|
||||
->assertSee('assigned')
|
||||
->assertSee('Same Person');
|
||||
});
|
||||
|
||||
it('isolates owner accountability and assigned work with separate personal filters', function (): void {
|
||||
[$viewer, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$otherOwner = tenantFindingUser($tenant, 'Other Owner');
|
||||
$otherAssignee = tenantFindingUser($tenant, 'Other Assignee');
|
||||
|
||||
$assignedToMe = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => (int) $otherOwner->getKey(),
|
||||
'assignee_user_id' => (int) $viewer->getKey(),
|
||||
]);
|
||||
|
||||
$ownedByMe = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_TRIAGED,
|
||||
'owner_user_id' => (int) $viewer->getKey(),
|
||||
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
||||
]);
|
||||
|
||||
$bothMine = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'owner_user_id' => (int) $viewer->getKey(),
|
||||
'assignee_user_id' => (int) $viewer->getKey(),
|
||||
]);
|
||||
|
||||
$neitherMine = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'owner_user_id' => (int) $otherOwner->getKey(),
|
||||
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($viewer);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->filterTable('my_assigned', true)
|
||||
->assertCanSeeTableRecords([$assignedToMe, $bothMine])
|
||||
->assertCanNotSeeTableRecords([$ownedByMe, $neitherMine])
|
||||
->removeTableFilter('my_assigned')
|
||||
->filterTable('my_accountability', true)
|
||||
->assertCanSeeTableRecords([$ownedByMe, $bothMine])
|
||||
->assertCanNotSeeTableRecords([$assignedToMe, $neitherMine]);
|
||||
});
|
||||
|
||||
it('keeps exception ownership visibly separate from finding ownership', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$findingOwner = tenantFindingUser($tenant, 'Finding Owner');
|
||||
$findingAssignee = tenantFindingUser($tenant, 'Finding Assignee');
|
||||
$exceptionOwner = tenantFindingUser($tenant, 'Exception Owner');
|
||||
|
||||
$findingWithException = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => (int) $findingOwner->getKey(),
|
||||
'assignee_user_id' => (int) $findingAssignee->getKey(),
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $findingWithException->getKey(),
|
||||
'requested_by_user_id' => (int) $owner->getKey(),
|
||||
'owner_user_id' => (int) $exceptionOwner->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Needs temporary governance coverage.',
|
||||
'requested_at' => now(),
|
||||
'review_due_at' => now()->addDays(7),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$requestFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => (int) $findingOwner->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $findingWithException], panel: 'tenant', tenant: $tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('Accountable owner')
|
||||
->assertSee('Active assignee')
|
||||
->assertSee('Exception owner')
|
||||
->assertSee('Finding Owner')
|
||||
->assertSee('Finding Assignee')
|
||||
->assertSee('Exception Owner');
|
||||
|
||||
$component = Livewire::test(ViewFinding::class, ['record' => $requestFinding->getKey()])
|
||||
->mountAction('request_exception');
|
||||
|
||||
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$form = $method->invoke($component->instance());
|
||||
|
||||
$field = collect($form?->getFlatFields(withHidden: true) ?? [])
|
||||
->first(fn (Field $field): bool => $field->getName() === 'owner_user_id');
|
||||
|
||||
$helperText = collect($field?->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
|
||||
->filter(fn (mixed $schemaComponent): bool => $schemaComponent instanceof Text)
|
||||
->map(fn (Text $schemaComponent): string => (string) $schemaComponent->getContent())
|
||||
->implode(' ');
|
||||
|
||||
expect($field?->getLabel())->toBe('Exception owner')
|
||||
->and($helperText)->toContain('Owns the exception record')
|
||||
->and($helperText)->toContain('not the finding outcome');
|
||||
});
|
||||
|
||||
it('allows in-scope members and returns 404 for non-members on tenant findings routes', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$member, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs($member)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||
->assertSuccessful();
|
||||
|
||||
$tenantInSameWorkspace = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
[$outsider] = createUserWithTenant(tenant: $tenantInSameWorkspace, role: 'owner');
|
||||
|
||||
$this->actingAs($outsider)
|
||||
->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
|
||||
$this->actingAs($outsider)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
function tenantFindingUser(Tenant $tenant, string $name): User
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenant, user: $user, role: 'operator');
|
||||
|
||||
return $user;
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
it('classifies assignment audit metadata for owner assignee and clear mutations', function (
|
||||
string $ownerTarget,
|
||||
string $assigneeTarget,
|
||||
string $expectedClassification,
|
||||
string $expectedSummary,
|
||||
): void {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$existingOwner = User::factory()->create(['name' => 'Existing Owner']);
|
||||
createUserWithTenant(tenant: $tenant, user: $existingOwner, role: 'manager');
|
||||
|
||||
$existingAssignee = User::factory()->create(['name' => 'Existing Assignee']);
|
||||
createUserWithTenant(tenant: $tenant, user: $existingAssignee, role: 'operator');
|
||||
|
||||
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
|
||||
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
|
||||
|
||||
$replacementAssignee = User::factory()->create(['name' => 'Replacement Assignee']);
|
||||
createUserWithTenant(tenant: $tenant, user: $replacementAssignee, role: 'operator');
|
||||
|
||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [
|
||||
'owner_user_id' => (int) $existingOwner->getKey(),
|
||||
'assignee_user_id' => (int) $existingAssignee->getKey(),
|
||||
]);
|
||||
|
||||
$ownerUserId = match ($ownerTarget) {
|
||||
'replacement' => (int) $replacementOwner->getKey(),
|
||||
'clear' => null,
|
||||
default => (int) $existingOwner->getKey(),
|
||||
};
|
||||
|
||||
$assigneeUserId = match ($assigneeTarget) {
|
||||
'replacement' => (int) $replacementAssignee->getKey(),
|
||||
'clear' => null,
|
||||
default => (int) $existingAssignee->getKey(),
|
||||
};
|
||||
|
||||
$updated = app(FindingWorkflowService::class)->assign(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
assigneeUserId: $assigneeUserId,
|
||||
ownerUserId: $ownerUserId,
|
||||
);
|
||||
|
||||
$audit = $this->latestFindingAudit($updated, 'finding.assigned');
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
expect(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe($expectedClassification)
|
||||
->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe($expectedSummary);
|
||||
|
||||
expect($updated->owner_user_id)->toBe($ownerUserId)
|
||||
->and($updated->assignee_user_id)->toBe($assigneeUserId);
|
||||
})->with([
|
||||
'owner only' => ['replacement', 'existing', 'owner_only', 'Updated the accountable owner and kept the active assignee unchanged.'],
|
||||
'assignee only' => ['existing', 'replacement', 'assignee_only', 'Updated the active assignee and kept the accountable owner unchanged.'],
|
||||
'owner and assignee' => ['replacement', 'replacement', 'owner_and_assignee', 'Updated the accountable owner and the active assignee.'],
|
||||
'clear owner' => ['clear', 'existing', 'clear_owner', 'Cleared the accountable owner and kept the active assignee unchanged.'],
|
||||
'clear assignee' => ['existing', 'clear', 'clear_assignee', 'Cleared the active assignee and kept the accountable owner unchanged.'],
|
||||
]);
|
||||
|
||||
it('preserves 403 semantics for in-scope members without assignment capability', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
|
||||
|
||||
expect(fn () => app(FindingWorkflowService::class)->assign(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $readonly,
|
||||
assigneeUserId: null,
|
||||
ownerUserId: (int) $owner->getKey(),
|
||||
))->toThrow(AuthorizationException::class);
|
||||
});
|
||||
@ -9,8 +9,12 @@
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Field;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -107,41 +111,113 @@
|
||||
->and($exception?->request_reason)->toBe('accepted by security');
|
||||
});
|
||||
|
||||
it('assigns owners and assignees via row action and rejects non-member ids', function (): void {
|
||||
it('keeps unchanged roles intact and exposes explicit assignment help text on row actions', function (): void {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($manager);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$assignee = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
$initialOwner = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $initialOwner, role: 'manager');
|
||||
|
||||
$initialAssignee = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $initialAssignee, role: 'operator');
|
||||
|
||||
$replacementOwner = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
|
||||
|
||||
$replacementAssignee = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $replacementAssignee, role: 'operator');
|
||||
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => (int) $initialOwner->getKey(),
|
||||
'assignee_user_id' => (int) $initialAssignee->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ListFindings::class);
|
||||
$component = Livewire::test(ListFindings::class)
|
||||
->mountTableAction('assign', $finding)
|
||||
->assertFormFieldExists('owner_user_id', function (Select $field): bool {
|
||||
$helperText = collect($field->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
|
||||
->filter(fn (mixed $component): bool => $component instanceof Text)
|
||||
->map(fn (Text $component): string => (string) $component->getContent())
|
||||
->implode(' ');
|
||||
|
||||
return $field->getLabel() === 'Accountable owner'
|
||||
&& str_contains($helperText, 'accountable for ensuring the finding reaches a governed outcome');
|
||||
})
|
||||
->assertFormFieldExists('assignee_user_id', function (Select $field): bool {
|
||||
$helperText = collect($field->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
|
||||
->filter(fn (mixed $component): bool => $component instanceof Text)
|
||||
->map(fn (Text $component): string => (string) $component->getContent())
|
||||
->implode(' ');
|
||||
|
||||
return $field->getLabel() === 'Active assignee'
|
||||
&& str_contains($helperText, 'currently expected to perform or coordinate the remediation work');
|
||||
});
|
||||
|
||||
$component
|
||||
->callTableAction('assign', $finding, [
|
||||
'assignee_user_id' => (int) $assignee->getKey(),
|
||||
'owner_user_id' => (int) $manager->getKey(),
|
||||
'assignee_user_id' => (int) $replacementAssignee->getKey(),
|
||||
'owner_user_id' => (int) $initialOwner->getKey(),
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$finding->refresh();
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey())
|
||||
->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey());
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey())
|
||||
->and((int) $finding->owner_user_id)->toBe((int) $initialOwner->getKey());
|
||||
|
||||
$component
|
||||
->callTableAction('assign', $finding, [
|
||||
'assignee_user_id' => (int) $replacementAssignee->getKey(),
|
||||
'owner_user_id' => (int) $replacementOwner->getKey(),
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$finding->refresh();
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey())
|
||||
->and((int) $finding->owner_user_id)->toBe((int) $replacementOwner->getKey());
|
||||
|
||||
$component
|
||||
->callTableAction('assign', $finding, [
|
||||
'assignee_user_id' => (int) $outsider->getKey(),
|
||||
'owner_user_id' => (int) $manager->getKey(),
|
||||
'owner_user_id' => (int) $replacementOwner->getKey(),
|
||||
]);
|
||||
|
||||
$finding->refresh();
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey());
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey())
|
||||
->and((int) $finding->owner_user_id)->toBe((int) $replacementOwner->getKey());
|
||||
});
|
||||
|
||||
it('returns 404 when a forged foreign-tenant assign row action is mounted', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$foreignFinding = Finding::factory()->for($tenantB)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ListFindings::class);
|
||||
|
||||
expect(fn () => $component->instance()->mountTableAction('assign', (string) $foreignFinding->getKey()))
|
||||
->toThrow(NotFoundHttpException::class);
|
||||
});
|
||||
|
||||
it('keeps the admin workflow surface scoped to the canonical tenant', function (): void {
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
it('enforces the canonical transition matrix for service-driven status changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -57,9 +58,13 @@
|
||||
ownerUserId: (int) $owner->getKey(),
|
||||
);
|
||||
|
||||
$audit = $this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned);
|
||||
|
||||
expect((int) $assignedFinding->assignee_user_id)->toBe((int) $assignee->getKey())
|
||||
->and((int) $assignedFinding->owner_user_id)->toBe((int) $owner->getKey())
|
||||
->and($this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned))->not->toBeNull();
|
||||
->and($audit)->not->toBeNull()
|
||||
->and(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe('owner_and_assignee')
|
||||
->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe('Updated the accountable owner and the active assignee.');
|
||||
|
||||
expect(fn () => $service->assign(
|
||||
finding: $assignedFinding,
|
||||
@ -70,6 +75,31 @@
|
||||
))->toThrow(\InvalidArgumentException::class, 'assignee_user_id must reference a current tenant member.');
|
||||
});
|
||||
|
||||
it('keeps 404 and 403 semantics distinct for assignment authorization', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$outsider = User::factory()->create();
|
||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
expect(fn () => $service->assign(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $outsider,
|
||||
assigneeUserId: null,
|
||||
ownerUserId: (int) $owner->getKey(),
|
||||
))->toThrow(NotFoundHttpException::class);
|
||||
|
||||
expect(fn () => $service->assign(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $readonly,
|
||||
assigneeUserId: null,
|
||||
ownerUserId: (int) $owner->getKey(),
|
||||
))->toThrow(AuthorizationException::class);
|
||||
});
|
||||
|
||||
it('requires explicit reasons for resolve close and risk accept mutations', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee($explanation?->headline ?? '')
|
||||
->assertSee('The snapshot finished processing, but its evidence basis is incomplete.')
|
||||
->assertSee($explanation?->evaluationResultLabel() ?? '')
|
||||
->assertSee($explanation?->trustworthinessLabel() ?? '')
|
||||
->assertSee('Partially complete')
|
||||
|
||||
@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
function governanceVisibleText(Testable $component): string
|
||||
{
|
||||
$html = $component->html();
|
||||
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
|
||||
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
|
||||
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
|
||||
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
|
||||
}
|
||||
|
||||
function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, OperationRun $run): Testable
|
||||
{
|
||||
Filament::setTenant(null, true);
|
||||
$testCase->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
return Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||
}
|
||||
|
||||
it('renders a summary-first hierarchy for zero-output baseline compare runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'coverage_unproven',
|
||||
'coverage' => [
|
||||
'proof' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'errors_recorded' => 1,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$component = governanceRunViewer($this, $user, $tenant, $run)
|
||||
->assertSee('Decision')
|
||||
->assertSee('Artifact impact')
|
||||
->assertSee('Dominant cause')
|
||||
->assertSee('Primary next step')
|
||||
->assertSee('The compare finished, but no decision-grade result is available yet.')
|
||||
->assertSee('Artifact truth details')
|
||||
->assertSee('Monitoring detail');
|
||||
|
||||
$pageText = governanceVisibleText($component);
|
||||
|
||||
expect(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'))
|
||||
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Monitoring detail'))
|
||||
->and($pageText)->toContain('no decision-grade result is available yet');
|
||||
});
|
||||
|
||||
it('keeps blocked baseline capture summaries ahead of diagnostics without adding new run-detail actions', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'reason_code' => 'missing_capability',
|
||||
'baseline_capture' => [
|
||||
'subjects_total' => 0,
|
||||
'gaps' => [
|
||||
'count' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
'failure_summary' => [[
|
||||
'reason_code' => 'missing_capability',
|
||||
'message' => 'A required capability is missing for this run.',
|
||||
]],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$component = governanceRunViewer($this, $user, $tenant, $run)
|
||||
->assertActionVisible('operate_hub_back_to_operations')
|
||||
->assertActionVisible('refresh')
|
||||
->assertSee('Blocked by prerequisite')
|
||||
->assertSee('No baseline was captured')
|
||||
->assertSee('Artifact impact')
|
||||
->assertSee('Dominant cause');
|
||||
|
||||
$pageText = governanceVisibleText($component);
|
||||
|
||||
expect(mb_substr_count($pageText, 'No baseline was captured'))->toBe(1)
|
||||
->and(mb_strpos($pageText, 'No baseline was captured'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||
});
|
||||
|
||||
it('shows processing outcome separately from artifact impact for stale evidence snapshot runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
|
||||
|
||||
$this->makeStaleArtifactTruthEvidenceSnapshot(
|
||||
tenant: $tenant,
|
||||
snapshotOverrides: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
governanceRunViewer($this, $user, $tenant, $run)
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Artifact impact')
|
||||
->assertSee('Completed successfully')
|
||||
->assertSee('The snapshot finished processing, but its evidence basis is already stale.')
|
||||
->assertSee('Result trust');
|
||||
});
|
||||
|
||||
it('preserves a dominant cause plus secondary causes for degraded review composition runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
|
||||
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant);
|
||||
|
||||
$this->makeArtifactTruthReview(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
snapshot: $snapshot,
|
||||
reviewOverrides: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'completeness_state' => 'partial',
|
||||
],
|
||||
summaryOverrides: [
|
||||
'section_state_counts' => [
|
||||
'complete' => 4,
|
||||
'partial' => 1,
|
||||
'missing' => 1,
|
||||
'stale' => 0,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$component = governanceRunViewer($this, $user, $tenant, $run)
|
||||
->assertSee('Dominant cause')
|
||||
->assertSee('Missing sections')
|
||||
->assertSee('Secondary causes')
|
||||
->assertSee('Stale evidence basis');
|
||||
|
||||
$pageText = governanceVisibleText($component);
|
||||
|
||||
expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes'))
|
||||
->and($pageText)->toContain('stale evidence');
|
||||
});
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -107,7 +107,7 @@
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->minimal()->forTenant($tenant)->create([
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => 123,
|
||||
],
|
||||
|
||||
@ -182,7 +182,7 @@
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
|
||||
@ -496,7 +496,7 @@
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => ['options' => ['include_foundations' => true]],
|
||||
|
||||
@ -45,3 +45,30 @@
|
||||
expect($runs->pluck('status')->unique()->values()->all())->toEqualCanonicalizing(['queued', 'running']);
|
||||
expect($runs->pluck('user_id')->all())->toContain($otherUser->id);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('suppresses stale backup set update runs from the progress widget', function (string $operationType): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'user_id' => $user->id,
|
||||
'type' => $operationType,
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'started_at' => null,
|
||||
'created_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns');
|
||||
|
||||
expect($component->get('runs'))->toBeInstanceOf(Collection::class)
|
||||
->and($component->get('runs'))->toHaveCount(0)
|
||||
->and($component->get('hasActiveRuns'))->toBeFalse();
|
||||
})->with([
|
||||
'backup set update' => 'backup_set.update',
|
||||
])->group('ops-ux');
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
})->group('ops-ux');
|
||||
|
||||
it('builds canonical already-queued toast copy', function (): void {
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('backup_set.update');
|
||||
|
||||
expect($toast->getTitle())->toBe('Backup set update already queued');
|
||||
expect($toast->getBody())->toBe('A matching operation is already queued or running. No action needed unless it stays stuck.');
|
||||
|
||||
@ -150,5 +150,5 @@
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('type', 'backup_set.update')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -136,6 +136,6 @@
|
||||
Queue::assertNothingPushed();
|
||||
Queue::assertNotPushed(RemovePoliciesFromBackupSetJob::class);
|
||||
|
||||
expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('type', 'backup_set.update')->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'directory_role_definitions.sync',
|
||||
'backup_set.update',
|
||||
'backup_schedule_run',
|
||||
'restore.execute',
|
||||
'tenant.review_pack.generate',
|
||||
@ -40,6 +41,8 @@
|
||||
|
||||
expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300)
|
||||
->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue()
|
||||
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
|
||||
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
|
||||
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)
|
||||
->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue();
|
||||
});
|
||||
|
||||
@ -16,17 +16,31 @@
|
||||
->toContain('inventory_sync', 'provider.inventory.sync');
|
||||
});
|
||||
|
||||
it('resolves canonical backup set update values without treating them as legacy aliases', function (): void {
|
||||
$resolution = OperationCatalog::resolve('backup_set.update');
|
||||
|
||||
expect($resolution->canonical->canonicalCode)->toBe('backup_set.update')
|
||||
->and($resolution->canonical->displayLabel)->toBe('Backup set update')
|
||||
->and($resolution->aliasStatus)->toBe('canonical')
|
||||
->and($resolution->wasLegacyAlias)->toBeFalse()
|
||||
->and(array_map(static fn ($alias): string => $alias->rawValue, $resolution->aliasesConsidered))
|
||||
->toBe(['backup_set.update']);
|
||||
});
|
||||
|
||||
it('deduplicates filter options by canonical operation code while preserving raw aliases for queries', function (): void {
|
||||
expect(OperationCatalog::filterOptions(['inventory_sync', 'provider.inventory.sync', 'policy.sync']))->toBe([
|
||||
'inventory.sync' => 'Inventory sync',
|
||||
'policy.sync' => 'Policy sync',
|
||||
])->and(OperationCatalog::rawValuesForCanonical('inventory.sync'))
|
||||
->toContain('inventory_sync', 'provider.inventory.sync');
|
||||
->toContain('inventory_sync', 'provider.inventory.sync')
|
||||
->and(OperationCatalog::rawValuesForCanonical('backup_set.update'))
|
||||
->toBe(['backup_set.update']);
|
||||
});
|
||||
|
||||
it('maps enum-backed storage values to canonical operation codes', function (): void {
|
||||
expect(OperationRunType::BaselineCompare->canonicalCode())->toBe('baseline.compare')
|
||||
->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync');
|
||||
->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync')
|
||||
->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update');
|
||||
});
|
||||
|
||||
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
|
||||
|
||||
@ -61,6 +61,32 @@
|
||||
expect($explanation->family)->toBe(ExplanationFamily::TrustworthyResult)
|
||||
->and($explanation->evaluationResult)->toBe('full_result')
|
||||
->and($explanation->trustworthinessLevel)->toBe(TrustworthinessLevel::Trustworthy)
|
||||
->and($explanation->nextActionCategory)->toBe('none')
|
||||
->and($explanation->nextActionText)->toBe('No action needed')
|
||||
->and($explanation->coverageStatement)->toContain('sufficient');
|
||||
});
|
||||
|
||||
it('maps retryable transient reasons into retry-later guidance', function (): void {
|
||||
$reason = $this->makeExplanationReasonEnvelope([
|
||||
'internalCode' => 'baseline_capture_transient_timeout',
|
||||
'operatorLabel' => 'Capture paused',
|
||||
'shortExplanation' => 'The capture hit a transient timeout while collecting evidence.',
|
||||
'actionability' => 'retryable_transient',
|
||||
'nextSteps' => [\App\Support\ReasonTranslation\NextStepOption::instruction('Retry the capture after worker capacity recovers.')],
|
||||
]);
|
||||
|
||||
$truth = $this->makeArtifactTruthEnvelope([
|
||||
'executionOutcome' => 'partially_succeeded',
|
||||
'artifactExistence' => 'created_but_not_usable',
|
||||
'contentState' => 'missing_input',
|
||||
'actionability' => 'required',
|
||||
'primaryLabel' => 'Artifact not usable',
|
||||
'primaryExplanation' => 'The capture did not finish cleanly enough to produce a usable artifact.',
|
||||
'nextActionLabel' => 'Retry the capture after worker capacity recovers',
|
||||
], $reason);
|
||||
|
||||
$explanation = app(OperatorExplanationBuilder::class)->fromArtifactTruthEnvelope($truth);
|
||||
|
||||
expect($explanation->nextActionCategory)->toBe('retry_later')
|
||||
->and($explanation->nextActionText)->toBe('Retry the capture after worker capacity recovers');
|
||||
});
|
||||
|
||||
@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
it('derives a blocked baseline capture summary with prerequisite-focused next steps', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'context' => [
|
||||
'reason_code' => 'missing_capability',
|
||||
'baseline_capture' => [
|
||||
'subjects_total' => 0,
|
||||
'gaps' => [
|
||||
'count' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
'failure_summary' => [[
|
||||
'reason_code' => 'missing_capability',
|
||||
'message' => 'A required capability is missing for this run.',
|
||||
]],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and($summary?->headline)->toContain('No baseline was captured')
|
||||
->and($summary?->dominantCause['label'])->toBe('No governed subjects captured')
|
||||
->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data')
|
||||
->and($summary?->affectedScaleCue['label'])->toBe('Capture scope')
|
||||
->and($summary?->affectedScaleCue['value'])->toContain('0 governed subjects');
|
||||
});
|
||||
|
||||
it('derives an ambiguous baseline compare summary with affected scale and scope review guidance', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'ambiguous_subjects',
|
||||
'subjects_total' => 12,
|
||||
'evidence_gaps' => [
|
||||
'count' => 4,
|
||||
],
|
||||
'coverage' => [
|
||||
'proof' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'errors_recorded' => 2,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and($summary?->headline)->toContain('ambiguous subject matching')
|
||||
->and($summary?->dominantCause['label'])->toBe('Ambiguous matches')
|
||||
->and($summary?->nextActionCategory)->toBe('review_scope_or_ambiguous_matches')
|
||||
->and($summary?->affectedScaleCue['label'])->toBe('Affected subjects')
|
||||
->and($summary?->affectedScaleCue['value'])->toContain('4 governed subjects');
|
||||
});
|
||||
|
||||
it('keeps execution outcome separate from artifact impact for stale evidence snapshot runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
|
||||
|
||||
$this->makeStaleArtifactTruthEvidenceSnapshot(
|
||||
tenant: $tenant,
|
||||
snapshotOverrides: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh());
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and($summary?->executionOutcomeLabel)->toBe('Completed successfully')
|
||||
->and($summary?->artifactImpactLabel)->not->toBe($summary?->executionOutcomeLabel)
|
||||
->and($summary?->headline)->toContain('stale')
|
||||
->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data');
|
||||
});
|
||||
|
||||
it('derives resume capture or generation when a compare run records a resume token', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'resume_token' => 'resume-token-220',
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and($summary?->nextActionCategory)->toBe('resume_capture_or_generation')
|
||||
->and($summary?->headline)->toContain('evidence capture still needs to resume');
|
||||
});
|
||||
|
||||
it('keeps deterministic multi-cause ordering for degraded review composition runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
|
||||
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant, [
|
||||
'operation_run_id' => null,
|
||||
]);
|
||||
|
||||
$this->makeArtifactTruthReview(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
snapshot: $snapshot,
|
||||
reviewOverrides: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'completeness_state' => 'partial',
|
||||
],
|
||||
summaryOverrides: [
|
||||
'section_state_counts' => [
|
||||
'complete' => 4,
|
||||
'partial' => 1,
|
||||
'missing' => 1,
|
||||
'stale' => 0,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$builder = app(GovernanceRunDiagnosticSummaryBuilder::class);
|
||||
$first = $builder->build($run->fresh());
|
||||
$second = $builder->build($run->fresh());
|
||||
|
||||
expect($first)->not->toBeNull()
|
||||
->and($second)->not->toBeNull()
|
||||
->and($first?->dominantCause['label'])->toBe('Missing sections')
|
||||
->and($first?->secondaryCauses[0]['label'] ?? null)->toBe('Stale evidence basis')
|
||||
->and($first?->secondaryCauses)->toEqual($second?->secondaryCauses)
|
||||
->and($first?->headline)->toContain('missing sections and stale evidence');
|
||||
});
|
||||
|
||||
it('derives no further action for publishable review pack runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review_pack.generate');
|
||||
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, [
|
||||
'operation_run_id' => null,
|
||||
]);
|
||||
$review = $this->makeArtifactTruthReview($tenant, $user, $snapshot, [
|
||||
'operation_run_id' => null,
|
||||
]);
|
||||
|
||||
$this->makeArtifactTruthReviewPack(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
snapshot: $snapshot,
|
||||
review: $review,
|
||||
packOverrides: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh());
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and($summary?->nextActionCategory)->toBe('no_further_action')
|
||||
->and($summary?->nextActionText)->toBe('No action needed.');
|
||||
});
|
||||
|
||||
it('does not invent new summary count keys while deriving scale cues', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'summary_counts' => [
|
||||
'total' => 7,
|
||||
'custom_noise' => 99,
|
||||
],
|
||||
'context' => [
|
||||
'baseline_compare' => [],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
||||
|
||||
expect($summary)->not->toBeNull()
|
||||
->and(array_keys(SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : [])))
|
||||
->toBe(['total'])
|
||||
->and($summary?->affectedScaleCue['source'])->toBe('summary_counts')
|
||||
->and($summary?->affectedScaleCue['label'])->toBe('Total');
|
||||
});
|
||||
@ -87,7 +87,7 @@
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'type' => 'backup_set.update',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-04-17
|
||||
**Last updated**: 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
@ -25,8 +25,8 @@ ### Governance & Architecture Hardening
|
||||
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands.
|
||||
|
||||
**Active specs**: 144
|
||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate)
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
|
||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||
|
||||
### UI & Product Maturity Polish
|
||||
@ -72,8 +72,8 @@ ## Planned (Next Quarter)
|
||||
|
||||
### R2 Completion — Evidence & Exception Workflows
|
||||
- Review pack export (Spec 109 — done)
|
||||
- Exception/risk-acceptance workflow for Findings → **Not yet specced**
|
||||
- Formal "evidence pack" entity → **Not yet specced**
|
||||
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
|
||||
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
|
||||
- Workspace-level PII override for review packs → deferred from 109
|
||||
|
||||
### Findings Workflow v2 / Execution Layer
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-04-19 (cleaned candidates already promoted to specs)
|
||||
**Last reviewed**: 2026-04-20 (reconciled promoted candidates with current specs)
|
||||
|
||||
---
|
||||
|
||||
@ -41,6 +41,9 @@ ## Promoted to Spec
|
||||
- Operator Explanation Layer for Degraded / Partial / Suppressed Results → Spec 161 (`operator-explanation-layer`)
|
||||
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
|
||||
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
|
||||
- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`)
|
||||
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||
- Governance Friction & Operator Vocabulary Hardening → Spec 194 (`governance-friction-hardening`)
|
||||
@ -142,122 +145,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||
|
||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||
- **Type**: hardening
|
||||
- **Source**: prerequisite-handling architecture analysis, provider dispatch gate architecture review, semantic clarity audit 2026-03-21
|
||||
- **Problem**: TenantPilot has two generations of provider-backed action dispatch patterns that produce inconsistent operator experiences for the same class of problem (missing prerequisites, blocked execution, concurrency collision):
|
||||
- **Gen 2 pattern** (correct, ~3 job types): `ProviderInventorySyncJob`, `ProviderConnectionHealthCheckJob`, `ProviderComplianceSnapshotJob` receive an explicit `providerConnectionId`, pass through `ProviderOperationStartGate` at dispatch time, and produce structured `ProviderOperationStartResult` envelopes with 4 clear states (`started`, `deduped`, `scope_busy`, `blocked`) plus structured reason codes and next-steps. Operators learn about blocked conditions **before** the job is queued.
|
||||
- **Gen 1 pattern** (inconsistent, ~20 services + their jobs): `ExecuteRestoreRunJob`, `EntraGroupSyncJob`, `SyncRoleDefinitionsJob`, policy sync jobs, and approximately 17 other services resolve connections implicitly at runtime via `MicrosoftGraphOptionsResolver::resolveForTenant()` or internal `resolveProviderConnection()` methods. These services bypass the gate entirely — prerequisites are not checked before dispatch. Blocked conditions are discovered asynchronously during job execution, producing runtime exceptions (`ProviderConfigurationRequiredException`, `RuntimeException`, `InvalidArgumentException`) that surface to operators as after-the-fact failed runs rather than preventable preflight blocks.
|
||||
- **Operator impact**: the same class of problem (missing provider connection, expired consent, invalid credentials, scope busy) produces two different operator experiences depending on which action triggered it. Gen 2 actions produce a clear "blocked" result with reason code and next-step guidance at the moment the operator clicks the button. Gen 1 actions silently queue, then fail asynchronously — the operator discovers the problem only when checking the operation run later, with a raw error message instead of structured guidance.
|
||||
- **Concurrency and deduplication gaps**: the `ProviderOperationStartGate` handles scope_busy / deduplication for Gen 2 operations, but Gen 1 operations have no equivalent deduplication — multiple restore or sync jobs for the same tenant/scope can be queued simultaneously, competing for the same provider connection without coordination.
|
||||
- **Notification inconsistency**: Gen 2 blocked results produce immediate toast/notification via `ProviderOperationStartResult` rendering in Filament actions. Gen 1 failures produce terminal `OperationRunCompleted` notifications with sanitized but still technical failure messages. The operator receives different feedback patterns for equivalent problems.
|
||||
- **Why it matters now**: As TenantPilot adds more provider domains (Entra roles, enterprise apps, SharePoint sharing), more operation types (baseline capture, drift detection, evidence generation), and more governance workflows (restore, review, compliance snapshot), every new provider-backed action that follows the Gen 1 implicit pattern reproduces the same operator experience gap. The Gen 2 pattern is proven, architecturally correct, and already handles the hard problems (connection locking, stale run detection, structured reason codes). The gap is not design — it is incomplete adoption. Additionally, the "Provider Connection Resolution Normalization" candidate addresses the backend plumbing problem (explicit connection ID passing), but does not address the operator-facing preflight/dispatch gate UX pattern. This candidate addresses the operator experience layer: ensuring that all provider-backed actions follow one canonical start path and that operators receive consistent, structured, before-dispatch feedback about prerequisites.
|
||||
- **Proposed direction**:
|
||||
- **Canonical dispatch entry point for all provider-backed actions**: all operator-triggered provider-backed actions (sync, backup, restore, health check, compliance snapshot, baseline capture, evidence generation, and future provider operations) must pass through a canonical preflight/dispatch gate before queuing. The existing `ProviderOperationStartGate` is the reference implementation; this candidate extends its scope to cover all provider-backed operation types, not just the current 3.
|
||||
- **Structured preflight result presentation contract**: define a shared Filament action result-rendering pattern for `ProviderOperationStartResult` states (`started`, `deduped`, `scope_busy`, `blocked`) so that every provider-backed action button produces the same UX feedback pattern. Currently, each Gen 2 consumer renders gate results with local if/else blocks — this should be a shared presenter or action mixin.
|
||||
- **Pre-queue prerequisite detection**: blocked conditions (missing connection, expired consent, invalid credentials, tenant not operable, scope busy, missing required permissions) must be detected and surfaced to the operator **before** the job is dispatched to the queue. Operators should never discover a preventable prerequisite failure only after checking a terminal `OperationRun` record.
|
||||
- **Dispatch-time connection locking for all operation types**: extend the `FOR UPDATE` row-locking pattern from Gen 2 to all provider-backed operations, preventing concurrent conflicting operations on the same provider connection.
|
||||
- **Deduplication/scope-busy enforcement for all operation types**: extend scope_busy/dedup detection to Gen 1 operations (restore, group sync, role sync, etc.) that currently lack it. Operators should receive "An operation of this type is already running for this tenant" feedback at click time, not discover it through a failed run.
|
||||
- **Unified next-steps for all blocked states**: extend the `ProviderNextStepsRegistry` pattern (or its successor from the Reason Code Translation candidate) to cover all provider-backed operation blocked states, not just provider connection codes. Every "blocked" gate result includes cause-specific next-action guidance.
|
||||
- **Operator notification alignment**: terminal notifications for provider-backed operations must follow the same structured pattern regardless of which generation of plumbing dispatched them. The notification should include: translated reason code (per Reason Code Translation contract), structured next-action guidance, and a link to the relevant resolution surface.
|
||||
- **Key decisions to encode**:
|
||||
- `ProviderOperationStartGate` (or its evolved successor) is the single canonical dispatch entry point — no provider-backed action bypasses it
|
||||
- Pre-queue prerequisite detection is a product guarantee for all provider operations — async-only failure discovery is an anti-pattern
|
||||
- Scope-busy / deduplication is mandatory for all provider operations, not just Gen 2
|
||||
- The gate result presentation is a shared UI contract, not a per-action local rendering decision
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: extending `ProviderOperationStartGate` scope to all provider-backed operation types, shared gate result presenter for Filament actions, pre-queue prerequisite detection for Gen 1 operations, scope-busy/dedup extension, next-steps enrichment for all gate blocked states, notification alignment for gate results, dispatch-time connection locking extension
|
||||
- **Out of scope**: backend connection resolution refactoring (tracked separately as "Provider Connection Resolution Normalization" — that candidate handles explicit `providerConnectionId` passing; this candidate handles the operator-facing gate/preflight layer), provider connection UX label changes (tracked as "Provider Connection UX Clarity"), legacy credential cleanup (tracked as "Provider Connection Legacy Cleanup"), adding new provider domains (domain expansion specs own that), operation naming vocabulary (tracked separately), reason code translation contract definition (tracked as "Operator Reason Code Translation" — this candidate consumes translated labels)
|
||||
- **Affected workflow families / surfaces**: All provider-backed Filament actions across TenantResource, ProviderConnectionResource, onboarding wizard, and future governance action surfaces. Approximately 20 services currently using Gen 1 implicit resolution. Notification templates for provider-backed operation terminal states. System console triage views for provider-related failures.
|
||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
||||
- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
|
||||
- **Priority**: high
|
||||
|
||||
### Governance Operator Outcome Compression
|
||||
- **Type**: hardening
|
||||
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
|
||||
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
|
||||
- **Proposed direction**:
|
||||
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
|
||||
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
|
||||
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
|
||||
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
|
||||
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
|
||||
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
|
||||
- **Primary adoption surfaces**:
|
||||
- Baseline snapshot lists and detail pages
|
||||
- Evidence snapshot lists and detail pages
|
||||
- Evidence overview
|
||||
- Tenant review lists and detail pages
|
||||
- Review register
|
||||
- Review pack lists and detail pages
|
||||
- Shared governance detail templates and artifact-truth presenter surfaces
|
||||
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
|
||||
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
|
||||
- **Core product principles to encode**:
|
||||
- One primary operator statement per artifact on scan surfaces
|
||||
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
|
||||
- Diagnostics are second-layer, not the default operator language
|
||||
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
|
||||
- Lists are scan surfaces, not diagnosis surfaces
|
||||
- **Candidate requirements**:
|
||||
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
|
||||
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
|
||||
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
|
||||
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
|
||||
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
|
||||
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
|
||||
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
|
||||
- **Acceptance points**:
|
||||
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
|
||||
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
|
||||
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
|
||||
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
|
||||
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
|
||||
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
|
||||
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
|
||||
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
|
||||
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
|
||||
- **Priority**: high
|
||||
|
||||
### Humanized Diagnostic Summaries for Governance Operations
|
||||
- **Type**: hardening
|
||||
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
|
||||
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
|
||||
- **Proposed direction**:
|
||||
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
|
||||
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
|
||||
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
|
||||
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
|
||||
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
|
||||
- Keep raw JSON and low-level context fully available, but explicitly secondary
|
||||
- **Primary adoption surfaces**:
|
||||
- Canonical Monitoring run-detail pages for governance operation types
|
||||
- Shared tenantless canonical run viewers and run-detail templates
|
||||
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
|
||||
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
|
||||
- **Acceptance points**:
|
||||
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
|
||||
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
|
||||
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
|
||||
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
||||
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
||||
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
|
||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
||||
- **Priority**: high
|
||||
|
||||
> **Operator Truth Initiative — Sequencing Note**
|
||||
>
|
||||
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
||||
@ -267,9 +154,9 @@ ### Humanized Diagnostic Summaries for Governance Operations
|
||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
|
||||
> 5. **Governance Operator Outcome Compression** — applies the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
||||
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4.
|
||||
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
||||
> 5. **Governance Operator Outcome Compression** — now promoted to Spec 214, applying the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first while preserving diagnostics as second-layer detail.
|
||||
> 6. **Humanized Diagnostic Summaries for Governance Operations** — now promoted to Spec 220 (`governance-run-summaries`), the run-detail explainability companion to compression that makes governance run detail self-explanatory using the explanation patterns established in step 4.
|
||||
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — now promoted to Spec 216, extending the proven Gen 2 gate pattern to all provider-backed operations and establishing a shared result presenter as the adjacent hardening lane.
|
||||
>
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
|
||||
>
|
||||
@ -327,7 +214,7 @@ ### Operation Run Active-State Visibility & Stale Escalation
|
||||
- tenant-scoped surfaces never show another tenant's runs
|
||||
- operations list clearly surfaces problematic active runs for fast scan
|
||||
- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces
|
||||
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane)
|
||||
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Spec 216 (Provider-Backed Action Preflight and Dispatch Gate Unification)
|
||||
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
||||
- **Priority**: high
|
||||
|
||||
@ -380,8 +267,8 @@ ### Baseline Compare Scope Guardrails & Ambiguity Guidance
|
||||
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
|
||||
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
|
||||
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Spec 161 (operator-explanation-layer), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation)
|
||||
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Spec 161 (operator-explanation-layer), Governance Operator Outcome Compression candidate
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Spec 161 (operator-explanation-layer), Spec 214 (Governance Operator Outcome Compression) as the complementary governance artifact presentation layer
|
||||
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Spec 161 (operator-explanation-layer), Spec 214 (Governance Operator Outcome Compression)
|
||||
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
|
||||
- **Priority**: high
|
||||
|
||||
@ -463,7 +350,7 @@ ### Tenant Operational Readiness & Status Truth Hierarchy
|
||||
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
||||
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
||||
- **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
||||
- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
||||
- **Boundary with Spec 214 (Governance Operator Outcome Compression)**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
||||
|
||||
199
specs/001-finding-ownership-semantics/plan.md
Normal file
199
specs/001-finding-ownership-semantics/plan.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Implementation Plan: Finding Ownership Semantics Clarification
|
||||
|
||||
**Branch**: `001-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/001-finding-ownership-semantics/spec.md`
|
||||
|
||||
**Note**: The setup script reported a numeric-prefix collision with `001-rbac-onboarding`, but it still resolved the active branch and plan path correctly to this feature directory. Planning continues against the current branch path.
|
||||
|
||||
## Summary
|
||||
|
||||
Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned
|
||||
**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact`
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production
|
||||
**Project Type**: Laravel monolith / Filament admin application
|
||||
**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations
|
||||
**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged
|
||||
**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native
|
||||
- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only
|
||||
- **State layers in scope**: page, detail, URL-query
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth.
|
||||
- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules.
|
||||
- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved.
|
||||
- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced.
|
||||
- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations.
|
||||
- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns.
|
||||
- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search.
|
||||
- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members.
|
||||
- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes.
|
||||
- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced.
|
||||
- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion.
|
||||
- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table.
|
||||
- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer.
|
||||
- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary.
|
||||
- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance.
|
||||
|
||||
**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions.
|
||||
- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users.
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests
|
||||
- **Escalation path**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-finding-ownership-semantics/
|
||||
├── plan.md
|
||||
├── spec.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── finding-responsibility.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ └── Resources/
|
||||
│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording
|
||||
│ ├── Models/
|
||||
│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed
|
||||
│ └── Services/
|
||||
│ └── Findings/
|
||||
│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only vs assignee-only changes
|
||||
│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment
|
||||
│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned-accountability semantics
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── Filament/
|
||||
│ └── Resources/
|
||||
│ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics
|
||||
└── Findings/
|
||||
├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics
|
||||
├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage
|
||||
└── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership.
|
||||
- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows.
|
||||
- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback.
|
||||
- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract.
|
||||
- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model.
|
||||
- **Release truth**: Current-release truth
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: [research.md](./research.md)
|
||||
|
||||
Research goals:
|
||||
- Confirm the existing source of truth for owner, assignee, and exception owner.
|
||||
- Confirm the smallest derived responsibility-state model that fits the current schema.
|
||||
- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives.
|
||||
- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- [data-model.md](./data-model.md)
|
||||
- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml)
|
||||
- [quickstart.md](./quickstart.md)
|
||||
|
||||
Design focus:
|
||||
- Keep responsibility truth on existing finding and finding-exception records.
|
||||
- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum.
|
||||
- Preserve exception owner as a separate governance concept when shown from a finding context.
|
||||
- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary.
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Surface semantics pass
|
||||
- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan.
|
||||
- Add a derived responsibility-state label or equivalent summary on list/detail surfaces.
|
||||
- Keep exception owner visibly separate from finding owner wherever both appear.
|
||||
|
||||
### Responsibility mutation clarity
|
||||
- Add owner/assignee help text to assignment flows.
|
||||
- Differentiate owner-only, assignee-only, and combined responsibility changes in operator feedback and audit-facing wording.
|
||||
- Keep current tenant-member validation and open-finding restrictions unchanged.
|
||||
|
||||
### Personal-work and next-action alignment
|
||||
- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate.
|
||||
- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps.
|
||||
|
||||
### Regression protection
|
||||
- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states.
|
||||
- Add focused responsibility-update tests for owner-only, assignee-only, and combined changes.
|
||||
- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed.
|
||||
|
||||
### Verification
|
||||
- Run the two focused Pest files and any directly modified sibling findings tests.
|
||||
- Run Pint on dirty files through Sail.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields.
|
||||
|
||||
## Filament v5 Agent Output Contract
|
||||
|
||||
1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+.
|
||||
2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||
3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search.
|
||||
4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry.
|
||||
5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added.
|
||||
6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned.
|
||||
204
specs/001-finding-ownership-semantics/spec.md
Normal file
204
specs/001-finding-ownership-semantics/spec.md
Normal file
@ -0,0 +1,204 @@
|
||||
# Feature Specification: Finding Ownership Semantics Clarification
|
||||
|
||||
**Feature Branch**: `001-finding-ownership-semantics`
|
||||
**Created**: 2026-04-20
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Finding Ownership Semantics Clarification"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution.
|
||||
- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy.
|
||||
- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly.
|
||||
- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence.
|
||||
- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout.
|
||||
- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts.
|
||||
- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow.
|
||||
- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}`
|
||||
- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface.
|
||||
- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page |
|
||||
| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned |
|
||||
| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none |
|
||||
| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception |
|
||||
| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation.
|
||||
- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces.
|
||||
- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy.
|
||||
- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract.
|
||||
- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract.
|
||||
- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth.
|
||||
- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes.
|
||||
- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior.
|
||||
- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes.
|
||||
- **Budget / baseline / trend impact**: none
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Route accountable ownership clearly (Priority: P1)
|
||||
|
||||
As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed.
|
||||
|
||||
**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning.
|
||||
|
||||
**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned.
|
||||
2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles.
|
||||
3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the accountability gap is surfaced as orphaned work.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Reassign work without losing accountability (Priority: P2)
|
||||
|
||||
As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability.
|
||||
|
||||
**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history.
|
||||
|
||||
**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, and combined changes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed.
|
||||
2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep exception ownership separate (Priority: P3)
|
||||
|
||||
As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner.
|
||||
|
||||
**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label.
|
||||
|
||||
**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner.
|
||||
2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error.
|
||||
- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state.
|
||||
- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill.
|
||||
- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split.
|
||||
|
||||
**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome.
|
||||
- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only.
|
||||
- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant.
|
||||
- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`.
|
||||
- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner.
|
||||
- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability.
|
||||
- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both.
|
||||
- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout.
|
||||
- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. |
|
||||
| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state.
|
||||
- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state.
|
||||
- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding.
|
||||
- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds.
|
||||
- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state.
|
||||
- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, and combined changes in operator feedback and audit-facing wording.
|
||||
- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice.
|
||||
- Open findings may legitimately begin without an assignee while still needing an accountable owner.
|
||||
- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Introduce team, queue, or workgroup ownership.
|
||||
- Add automatic escalation, reassignment, or inactivity timers.
|
||||
- Split authorization into separate owner-edit and assignee-edit capabilities.
|
||||
- Require a mandatory historical backfill before the clarified semantics can ship.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies.
|
||||
- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner.
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Finding Ownership Semantics Clarification
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-20
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated against the existing findings domain contract: finding owner versus finding assignee versus exception owner.
|
||||
- Scope remains intentionally narrow: no new queue model, capability split, persistence, or ownership framework was introduced.
|
||||
@ -0,0 +1,248 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot Internal Finding Responsibility Contract
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Internal review contract for Spec 219.
|
||||
|
||||
These are operator-facing Filament surfaces, not public API endpoints. The document exists so
|
||||
the feature has an explicit, reviewable contract under specs/.../contracts/.
|
||||
|
||||
paths:
|
||||
/admin/t/{tenant}/findings:
|
||||
get:
|
||||
summary: Tenant findings list with explicit responsibility semantics
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPathParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Findings list rendered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingListSurface'
|
||||
/admin/t/{tenant}/findings/{finding}:
|
||||
get:
|
||||
summary: Finding detail with owner, assignee, and optional exception-owner context
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPathParam'
|
||||
- $ref: '#/components/parameters/FindingPathParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding detail rendered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetailSurface'
|
||||
/admin/t/{tenant}/findings/{finding}/responsibility:
|
||||
post:
|
||||
summary: Update finding owner and assignee semantics
|
||||
description: Conceptual responsibility-update contract implemented by Filament action endpoints.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPathParam'
|
||||
- $ref: '#/components/parameters/FindingPathParam'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResponsibilityUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Responsibility updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResponsibilityUpdateResult'
|
||||
'403':
|
||||
description: Actor is an in-scope tenant member but lacks assignment capability
|
||||
'404':
|
||||
description: Tenant or finding is outside the actor scope
|
||||
/admin/t/{tenant}/findings/{finding}/exception-request:
|
||||
post:
|
||||
summary: Request exception with explicitly separate exception owner
|
||||
description: Conceptual exception-request contract implemented by Filament action endpoints.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantPathParam'
|
||||
- $ref: '#/components/parameters/FindingPathParam'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExceptionRequestInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Exception request accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExceptionOwnershipBoundaryResult'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantPathParam:
|
||||
name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Tenant route identifier for tenant-scoped findings surfaces.
|
||||
FindingPathParam:
|
||||
name: finding
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Tenant-owned finding identifier.
|
||||
|
||||
schemas:
|
||||
UserReference:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- display_name
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
display_name:
|
||||
type: string
|
||||
|
||||
ResponsibilityState:
|
||||
type: string
|
||||
description: Uses internal slugs for API and test contracts. The operator-facing UI label for `orphaned_accountability` is `orphaned accountability`.
|
||||
enum:
|
||||
- orphaned_accountability
|
||||
- owned_unassigned
|
||||
- assigned
|
||||
|
||||
FindingResponsibilitySummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- finding_id
|
||||
- workflow_status
|
||||
- responsibility_state
|
||||
properties:
|
||||
finding_id:
|
||||
type: integer
|
||||
workflow_status:
|
||||
type: string
|
||||
owner:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserReference'
|
||||
nullable: true
|
||||
assignee:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserReference'
|
||||
nullable: true
|
||||
responsibility_state:
|
||||
$ref: '#/components/schemas/ResponsibilityState'
|
||||
accountability_gap:
|
||||
type: boolean
|
||||
exception_owner:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserReference'
|
||||
nullable: true
|
||||
description: Present only when a finding exception exists or is shown in current context.
|
||||
|
||||
FindingListSurface:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surface
|
||||
- collection_route
|
||||
- primary_inspect_model
|
||||
- items
|
||||
properties:
|
||||
surface:
|
||||
const: tenant_findings_list
|
||||
collection_route:
|
||||
const: /admin/t/{tenant}/findings
|
||||
primary_inspect_model:
|
||||
const: finding
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FindingResponsibilitySummary'
|
||||
|
||||
FindingDetailSurface:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/FindingResponsibilitySummary'
|
||||
- type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surface
|
||||
- detail_route
|
||||
properties:
|
||||
surface:
|
||||
const: finding_detail
|
||||
detail_route:
|
||||
const: /admin/t/{tenant}/findings/{finding}
|
||||
|
||||
ResponsibilityUpdateRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
owner_user_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Accountable owner for the finding outcome.
|
||||
assignee_user_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Active remediation assignee for the finding.
|
||||
|
||||
ResponsibilityUpdateResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- change_classification
|
||||
- responsibility
|
||||
properties:
|
||||
change_classification:
|
||||
type: string
|
||||
description: Uses explicit slugs for single-role clears and `owner_and_assignee` whenever both fields change in one update, including mixed set/clear combinations.
|
||||
enum:
|
||||
- owner_only
|
||||
- assignee_only
|
||||
- owner_and_assignee
|
||||
- clear_owner
|
||||
- clear_assignee
|
||||
responsibility:
|
||||
$ref: '#/components/schemas/FindingResponsibilitySummary'
|
||||
|
||||
ExceptionRequestInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- owner_user_id
|
||||
- request_reason
|
||||
- review_due_at
|
||||
properties:
|
||||
owner_user_id:
|
||||
type: integer
|
||||
description: Owner of the exception artifact, not the finding owner.
|
||||
request_reason:
|
||||
type: string
|
||||
review_due_at:
|
||||
type: string
|
||||
format: date-time
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
|
||||
ExceptionOwnershipBoundaryResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- finding_owner_preserved
|
||||
- exception_owner
|
||||
properties:
|
||||
finding_owner_preserved:
|
||||
type: boolean
|
||||
const: true
|
||||
exception_owner:
|
||||
$ref: '#/components/schemas/UserReference'
|
||||
121
specs/219-finding-ownership-semantics/data-model.md
Normal file
121
specs/219-finding-ownership-semantics/data-model.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Data Model: Finding Ownership Semantics Clarification
|
||||
|
||||
**Date**: 2026-04-20
|
||||
**Branch**: `219-finding-ownership-semantics`
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entities. It clarifies responsibility semantics over existing finding and finding-exception records and adds one derived responsibility-state projection for operator-facing surfaces.
|
||||
|
||||
## Entity: Finding
|
||||
|
||||
**Represents**: A tenant-owned operational governance finding that moves through the findings workflow and may carry both accountable ownership and active remediation assignment.
|
||||
|
||||
### Key Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | bigint | yes | Primary key |
|
||||
| `workspace_id` | bigint | yes | Derived tenant ownership boundary |
|
||||
| `tenant_id` | bigint | yes | Tenant isolation boundary |
|
||||
| `status` | string | yes | Existing findings lifecycle state |
|
||||
| `severity` | string | yes | Existing severity dimension |
|
||||
| `owner_user_id` | bigint nullable | no | Accountable person for the finding outcome |
|
||||
| `assignee_user_id` | bigint nullable | no | Active remediation executor / coordinator |
|
||||
| `due_at` | datetime nullable | no | Existing SLA/follow-up deadline |
|
||||
| `resolved_reason` | string nullable | no | Existing closure context |
|
||||
| `closed_reason` | string nullable | no | Existing closure/governance context |
|
||||
|
||||
### Relationships
|
||||
|
||||
| Relationship | Target | Cardinality | Purpose |
|
||||
|---|---|---|---|
|
||||
| `tenant()` | `Tenant` | belongsTo | Tenant ownership and authorization |
|
||||
| `ownerUser()` | `User` | belongsTo | Accountable owner |
|
||||
| `assigneeUser()` | `User` | belongsTo | Active remediation assignee |
|
||||
| `findingException()` | `FindingException` | hasOne | Optional exception artifact for accepted-risk governance |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `owner_user_id` MAY be null.
|
||||
- `assignee_user_id` MAY be null.
|
||||
- If present, either user ID MUST reference a current member of the active tenant.
|
||||
- Responsibility changes are allowed only on open findings, matching the current `FindingWorkflowService::assign()` rule.
|
||||
|
||||
## Entity: FindingException
|
||||
|
||||
**Represents**: A tenant-owned exception artifact attached to a finding when governance coverage is requested or granted.
|
||||
|
||||
### Key Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | bigint | yes | Primary key |
|
||||
| `finding_id` | bigint | yes | Owning finding |
|
||||
| `tenant_id` | bigint | yes | Tenant isolation boundary |
|
||||
| `owner_user_id` | bigint nullable | no | Accountable owner of the exception artifact, not of the finding itself |
|
||||
| `status` | string | yes | Existing exception lifecycle state |
|
||||
| `current_validity_state` | string nullable | no | Existing governance-validity dimension |
|
||||
| `request_reason` | text | yes | Existing request context |
|
||||
|
||||
### Relationships
|
||||
|
||||
| Relationship | Target | Cardinality | Purpose |
|
||||
|---|---|---|---|
|
||||
| `finding()` | `Finding` | belongsTo | Parent finding context |
|
||||
| `owner()` | `User` | belongsTo | Exception artifact owner |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- Exception-owner selection continues to use current tenant-member validation.
|
||||
- Exception ownership MUST remain semantically distinct from finding ownership on all mixed-context surfaces.
|
||||
|
||||
## Derived Projection: ResponsibilityState
|
||||
|
||||
**Represents**: An operator-facing derived state computed from `owner_user_id` and `assignee_user_id` without new persistence.
|
||||
|
||||
**Naming convention**:
|
||||
|
||||
- Operator-facing UI label: `orphaned accountability`
|
||||
- Internal derived-state and contract slug: `orphaned_accountability`
|
||||
|
||||
### Derived Values
|
||||
|
||||
| Derived State | Rule | Operator Meaning |
|
||||
|---|---|---|
|
||||
| `orphaned_accountability` | `owner_user_id == null` | No accountable owner is set. This remains true even if an assignee exists. |
|
||||
| `owned_unassigned` | `owner_user_id != null && assignee_user_id == null` | Someone owns the outcome, but active remediation work is not assigned. |
|
||||
| `assigned` | `owner_user_id != null && assignee_user_id != null` | Accountability and active remediation assignment are both set. |
|
||||
|
||||
### Rendering Notes
|
||||
|
||||
- If owner and assignee are the same user, the state remains `assigned`; the UI should show both roles satisfied without implying a data problem.
|
||||
- If both are null, the finding still uses the slug `orphaned_accountability` and the visible label `orphaned accountability`.
|
||||
- If assignee is present but owner is null, the finding remains `orphaned_accountability`; the UI may also show that remediation is assigned without accountable ownership.
|
||||
|
||||
## Mutation Contract: ResponsibilityUpdate
|
||||
|
||||
**Represents**: The input/output contract of the existing assignment action.
|
||||
|
||||
### Input Shape
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `owner_user_id` | bigint nullable | no | Set, change, or clear finding owner |
|
||||
| `assignee_user_id` | bigint nullable | no | Set, change, or clear finding assignee |
|
||||
|
||||
### Behavioral Rules
|
||||
|
||||
- The existing `FindingWorkflowService::assign()` method remains the mutation boundary.
|
||||
- The service MUST continue to write both fields explicitly to the finding.
|
||||
- Operator feedback and audit-facing wording should classify the result as `owner_only`, `assignee_only`, `clear_owner`, `clear_assignee`, or `owner_and_assignee` when both fields change in one update.
|
||||
|
||||
## State and Lifecycle Impact
|
||||
|
||||
This feature does not add a new lifecycle family. It overlays responsibility semantics on top of existing findings lifecycle states.
|
||||
|
||||
| Existing Lifecycle State | Responsibility Impact |
|
||||
|---|---|
|
||||
| `new`, `triaged`, `in_progress`, `reopened`, `acknowledged` | Responsibility state is actionable and visible by default |
|
||||
| `resolved`, `closed` | Responsibility remains historical context only |
|
||||
| `risk_accepted` | Responsibility remains visible, but exception-owner context may also appear and must remain separate |
|
||||
198
specs/219-finding-ownership-semantics/plan.md
Normal file
198
specs/219-finding-ownership-semantics/plan.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Implementation Plan: Finding Ownership Semantics Clarification
|
||||
|
||||
**Branch**: `219-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/219-finding-ownership-semantics/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned
|
||||
**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact`
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production
|
||||
**Project Type**: Laravel monolith / Filament admin application
|
||||
**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations
|
||||
**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged
|
||||
**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native
|
||||
- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only
|
||||
- **State layers in scope**: page, detail, URL-query
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth.
|
||||
- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules.
|
||||
- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved.
|
||||
- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced.
|
||||
- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations.
|
||||
- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns.
|
||||
- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search.
|
||||
- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members.
|
||||
- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes.
|
||||
- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced.
|
||||
- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion.
|
||||
- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table.
|
||||
- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer.
|
||||
- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary.
|
||||
- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance.
|
||||
|
||||
**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Test governance outcome**: document-in-feature
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions.
|
||||
- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users.
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests
|
||||
- **Escalation path**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/219-finding-ownership-semantics/
|
||||
├── plan.md
|
||||
├── spec.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── finding-responsibility.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ └── Resources/
|
||||
│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording
|
||||
│ ├── Models/
|
||||
│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed
|
||||
│ └── Services/
|
||||
│ └── Findings/
|
||||
│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only, assignee-only, clear, and combined changes
|
||||
│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment
|
||||
│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned accountability semantics
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── Filament/
|
||||
│ └── Resources/
|
||||
│ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics
|
||||
└── Findings/
|
||||
├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics
|
||||
├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage
|
||||
└── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership.
|
||||
- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows.
|
||||
- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback.
|
||||
- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract.
|
||||
- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model.
|
||||
- **Release truth**: Current-release truth
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: [research.md](./research.md)
|
||||
|
||||
Research goals:
|
||||
- Confirm the existing source of truth for owner, assignee, and exception owner.
|
||||
- Confirm the smallest derived responsibility-state model that fits the current schema.
|
||||
- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives.
|
||||
- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- [data-model.md](./data-model.md)
|
||||
- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml)
|
||||
- [quickstart.md](./quickstart.md)
|
||||
|
||||
Design focus:
|
||||
- Keep responsibility truth on existing finding and finding-exception records.
|
||||
- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum.
|
||||
- Preserve exception owner as a separate governance concept when shown from a finding context.
|
||||
- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary.
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Surface semantics pass
|
||||
- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan.
|
||||
- Add a derived responsibility-state label or equivalent summary on list/detail surfaces.
|
||||
- Keep exception owner visibly separate from finding owner wherever both appear.
|
||||
|
||||
### Responsibility mutation clarity
|
||||
- Add owner/assignee help text to assignment flows.
|
||||
- Differentiate owner-only, assignee-only, clear-owner, clear-assignee, and combined responsibility changes in operator feedback and audit-facing wording.
|
||||
- Keep current tenant-member validation and open-finding restrictions unchanged.
|
||||
|
||||
### Personal-work and next-action alignment
|
||||
- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate.
|
||||
- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps.
|
||||
|
||||
### Regression protection
|
||||
- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states.
|
||||
- Add focused responsibility-update tests for owner-only, assignee-only, clear-owner, clear-assignee, and combined changes.
|
||||
- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed.
|
||||
|
||||
### Verification
|
||||
- Run the two focused Pest files and any directly modified sibling findings tests.
|
||||
- Run Pint on dirty files through Sail.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields.
|
||||
|
||||
## Filament v5 Agent Output Contract
|
||||
|
||||
1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+.
|
||||
2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||
3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search.
|
||||
4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry.
|
||||
5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added.
|
||||
6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned.
|
||||
81
specs/219-finding-ownership-semantics/quickstart.md
Normal file
81
specs/219-finding-ownership-semantics/quickstart.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Quickstart: Finding Ownership Semantics Clarification
|
||||
|
||||
**Goal**: Implement the clarified finding owner versus assignee contract on existing findings surfaces without introducing new persistence, capabilities, or workflow services.
|
||||
|
||||
## 1. Prepare the workspace
|
||||
|
||||
```bash
|
||||
cd apps/platform
|
||||
./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
## 2. Update responsibility semantics on the existing findings resource
|
||||
|
||||
Primary file:
|
||||
|
||||
- `app/Filament/Resources/FindingResource.php`
|
||||
|
||||
Expected implementation steps:
|
||||
|
||||
1. Keep owner and assignee as separate roles on list and detail surfaces.
|
||||
2. Add a derived responsibility-state label, badge, or equivalent summary based on current owner/assignee presence.
|
||||
3. Adjust filters or personal-work shortcuts so assignee-driven work and owner-driven accountability are not collapsed into one ambiguous view.
|
||||
4. Keep `Exception owner` explicitly distinct anywhere exception context is rendered from a finding.
|
||||
5. Add help text to assignment and exception-request forms so operators understand the semantic difference between the two owner concepts.
|
||||
|
||||
## 3. Keep responsibility truth local and derived
|
||||
|
||||
Supporting files:
|
||||
|
||||
- `app/Models/Finding.php`
|
||||
- `app/Services/Findings/FindingWorkflowService.php`
|
||||
- `app/Services/Findings/FindingExceptionService.php`
|
||||
- `app/Services/Findings/FindingRiskGovernanceResolver.php`
|
||||
|
||||
Guidance:
|
||||
|
||||
1. Prefer a small local derived helper on `Finding` if it simplifies repeated responsibility-state checks.
|
||||
2. Do not add a new enum, table, or presenter for responsibility state.
|
||||
3. Keep `FindingWorkflowService::assign()` as the canonical mutation boundary.
|
||||
4. If feedback or audit wording changes, distinguish owner-only, assignee-only, clear-owner, clear-assignee, and combined changes explicitly.
|
||||
5. If next-action copy is updated, treat missing owner as the visible state `orphaned accountability` even when an assignee exists.
|
||||
|
||||
## 4. Add focused regression tests
|
||||
|
||||
Primary test targets:
|
||||
|
||||
- `tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||
- `tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||
|
||||
Potential supporting edits:
|
||||
|
||||
- `tests/Feature/Findings/FindingWorkflowRowActionsTest.php`
|
||||
- `tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||
|
||||
Coverage checklist:
|
||||
|
||||
1. Owner-only finding renders as owned but unassigned.
|
||||
2. Owner-plus-assignee finding renders both roles distinctly.
|
||||
3. Assignee-only and both-null findings render as `orphaned accountability`.
|
||||
4. Exception owner remains separately labeled from finding owner.
|
||||
5. Responsibility updates preserve tenant-member validation and clearly report owner-only, assignee-only, clear-owner, clear-assignee, and combined changes.
|
||||
6. Tenant-route authorization assertions use explicit panel selection when needed.
|
||||
|
||||
## 5. Verify the feature
|
||||
|
||||
Run the narrowest proof set first:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## 6. Review expectations
|
||||
|
||||
Before moving to tasks or implementation review, confirm:
|
||||
|
||||
1. Owner, assignee, and exception owner mean one stable thing each across list, detail, and action flows.
|
||||
2. Responsibility state is derived from existing fields only.
|
||||
3. No new persistence, capability split, or presenter/framework layer was introduced.
|
||||
4. Tenant-safe Filament behavior remains intact on both admin canonical and tenant-panel test paths.
|
||||
52
specs/219-finding-ownership-semantics/research.md
Normal file
52
specs/219-finding-ownership-semantics/research.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Research: Finding Ownership Semantics Clarification
|
||||
|
||||
**Date**: 2026-04-20
|
||||
**Branch**: `219-finding-ownership-semantics`
|
||||
|
||||
## Decision 1: Reuse the existing finding owner and assignee fields as the only responsibility truth
|
||||
|
||||
- **Decision**: Keep `findings.owner_user_id` and `findings.assignee_user_id` as the sole persisted responsibility fields and derive any responsibility-state wording from them.
|
||||
- **Rationale**: The current model, workflow service, migrations, and tests already support independent owner and assignee assignment. The spec problem is semantic ambiguity, not missing persistence.
|
||||
- **Alternatives considered**:
|
||||
- Add a new persisted responsibility status column. Rejected because the state is directly derivable from existing nullable fields.
|
||||
- Introduce a separate responsibility aggregate/service object. Rejected because one resource and one model can express the needed truth without adding a new layer.
|
||||
|
||||
## Decision 2: Treat assignee-without-owner as an accountability gap, not a healthy assigned state
|
||||
|
||||
- **Decision**: Use a derived responsibility contract where owner presence determines accountability. `owner != null && assignee == null` is `owned but unassigned`; `owner != null && assignee != null` is `assigned`; any `owner == null` case is an accountability gap, even if an assignee exists.
|
||||
- **Rationale**: The spec defines owner as accountable for the outcome and assignee as actively executing work. Existing resolver logic already treats missing owner or missing assignee as follow-up needing attention.
|
||||
- **Alternatives considered**:
|
||||
- Treat any assignee as `assigned` even without an owner. Rejected because it hides the exact accountability gap the feature exists to surface.
|
||||
- Introduce a fourth persisted state family. Rejected because the business consequence is presentation and next-action guidance, not new workflow routing.
|
||||
|
||||
## Decision 3: Keep exception owner explicitly separate from finding owner
|
||||
|
||||
- **Decision**: Preserve `finding_exceptions.owner_user_id` as a distinct governance concept and label it only as `Exception owner` when shown in a finding context.
|
||||
- **Rationale**: The current resource already surfaces exception owner separately, and the spec explicitly calls out the risk of accepted-risk flows reintroducing ambiguity under a second `Owner` label.
|
||||
- **Alternatives considered**:
|
||||
- Collapse exception owner into finding owner language. Rejected because it would blur responsibility for the finding with responsibility for the exception artifact.
|
||||
- Rename finding owner around exception flows only. Rejected because local relabeling would increase rather than reduce semantic drift.
|
||||
|
||||
## Decision 4: Keep the implementation local to the existing findings resource and current workflow services
|
||||
|
||||
- **Decision**: Make the semantics change in `FindingResource`, with only small supporting adjustments in `Finding`, `FindingWorkflowService`, `FindingExceptionService`, and `FindingRiskGovernanceResolver` if wording or derived next-action copy needs alignment.
|
||||
- **Rationale**: The current UI already displays owner, assignee, and exception owner. The gap is where those semantics are explained, prioritized, and tested.
|
||||
- **Alternatives considered**:
|
||||
- Add a new presenter or explanation layer for responsibility semantics. Rejected because a local derived helper is sufficient and aligned with the constitution’s anti-layering bias.
|
||||
- Add a dedicated responsibility page. Rejected because the findings list/detail surfaces are already the operator decision context.
|
||||
|
||||
## Decision 5: Use focused Pest feature + Livewire coverage, not browser or heavy-governance tests
|
||||
|
||||
- **Decision**: Cover the feature with focused findings resource and workflow feature tests, including explicit tenant-context setup and Filament routing discipline.
|
||||
- **Rationale**: Existing tests already prove tenant-member validation and row-action behavior. The missing coverage is list/detail semantics and owner-only versus assignee-only feedback.
|
||||
- **Alternatives considered**:
|
||||
- Browser tests. Rejected because the behavior is visible and provable through current Filament Livewire test patterns.
|
||||
- Unit-only tests. Rejected because they would miss the operator-facing semantics that motivated the spec.
|
||||
|
||||
## Decision 6: Preserve current tenant-panel and canonical-admin context rules during testing
|
||||
|
||||
- **Decision**: Keep tenant context explicit in tests and use explicit panel selection for tenant-route assertions when necessary.
|
||||
- **Rationale**: Repository memory shows two relevant pitfalls: tenant-scoped Filament list pages can lose context on later Livewire interactions, and `Resource::getUrl(..., tenant: $tenant)` defaults to the admin panel unless `panel: 'tenant'` is passed.
|
||||
- **Alternatives considered**:
|
||||
- Rely on implicit panel resolution in tests. Rejected because it can produce false redirects to `/admin/choose-tenant` instead of the intended authorization result.
|
||||
- Broaden shared fixtures to always set tenant context. Rejected because the constitution prefers opt-in context rather than expensive defaults.
|
||||
210
specs/219-finding-ownership-semantics/spec.md
Normal file
210
specs/219-finding-ownership-semantics/spec.md
Normal file
@ -0,0 +1,210 @@
|
||||
# Feature Specification: Finding Ownership Semantics Clarification
|
||||
|
||||
**Feature Branch**: `219-finding-ownership-semantics`
|
||||
**Created**: 2026-04-20
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Finding Ownership Semantics Clarification"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution.
|
||||
- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy.
|
||||
- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly.
|
||||
- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence.
|
||||
- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout.
|
||||
- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts.
|
||||
- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow.
|
||||
- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}`
|
||||
- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface.
|
||||
- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page |
|
||||
| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned |
|
||||
| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none |
|
||||
| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception |
|
||||
| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation.
|
||||
- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces.
|
||||
- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy.
|
||||
- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract.
|
||||
- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract.
|
||||
- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Test governance outcome**: document-in-feature
|
||||
- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth.
|
||||
- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes.
|
||||
- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior.
|
||||
- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes.
|
||||
- **Budget / baseline / trend impact**: none
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Route accountable ownership clearly (Priority: P1)
|
||||
|
||||
As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed.
|
||||
|
||||
**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning.
|
||||
|
||||
**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned.
|
||||
2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles.
|
||||
3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the record is surfaced with the derived state `orphaned accountability`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Reassign work without losing accountability (Priority: P2)
|
||||
|
||||
As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability.
|
||||
|
||||
**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history.
|
||||
|
||||
**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, combined, clear-owner, and clear-assignee changes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed.
|
||||
2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed.
|
||||
3. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes both roles in one responsibility update, **Then** the resulting feedback states that both the owner and assignee changed.
|
||||
4. **Given** an open finding with an existing owner and assignee, **When** an authorized operator clears only the assignee, **Then** the owner remains unchanged and the finding returns to `owned but unassigned`.
|
||||
5. **Given** an open finding with an existing owner and assignee, **When** an authorized operator clears only the owner, **Then** the assignee remains unchanged and the finding returns to `orphaned accountability`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep exception ownership separate (Priority: P3)
|
||||
|
||||
As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner.
|
||||
|
||||
**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label.
|
||||
|
||||
**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner.
|
||||
2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error.
|
||||
- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state.
|
||||
- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill.
|
||||
- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split.
|
||||
|
||||
**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome.
|
||||
- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only.
|
||||
- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant.
|
||||
- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`.
|
||||
- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner.
|
||||
- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability.
|
||||
- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both.
|
||||
- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout.
|
||||
- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner.
|
||||
|
||||
**State naming convention**: Operator-facing copy uses `orphaned accountability` as the visible label. Internal derived-state and contract slugs use `orphaned_accountability`.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. |
|
||||
| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state.
|
||||
- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state.
|
||||
- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding.
|
||||
- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds.
|
||||
- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state.
|
||||
- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, clear-owner, clear-assignee, and combined changes in operator feedback and audit-facing wording.
|
||||
- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice.
|
||||
- Open findings may legitimately begin without an assignee while still needing an accountable owner.
|
||||
- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Introduce team, queue, or workgroup ownership.
|
||||
- Add automatic escalation, reassignment, or inactivity timers.
|
||||
- Split authorization into separate owner-edit and assignee-edit capabilities.
|
||||
- Require a mandatory historical backfill before the clarified semantics can ship.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies.
|
||||
- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner.
|
||||
207
specs/219-finding-ownership-semantics/tasks.md
Normal file
207
specs/219-finding-ownership-semantics/tasks.md
Normal file
@ -0,0 +1,207 @@
|
||||
# Tasks: Finding Ownership Semantics Clarification
|
||||
|
||||
**Input**: Design documents from `/specs/219-finding-ownership-semantics/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/finding-responsibility.openapi.yaml, quickstart.md
|
||||
|
||||
**Tests**: Tests are REQUIRED for this runtime behavior change. Use focused Pest feature and Livewire tests via Sail.
|
||||
**Test Governance Outcome**: `document-in-feature`
|
||||
**Operations**: No new `OperationRun` work is required for this feature; responsibility changes remain tenant-local audited writes.
|
||||
**RBAC**: No new authorization model is introduced; tasks must preserve existing tenant membership isolation, `404` versus `403` semantics, and canonical findings capability enforcement.
|
||||
**Filament UI Action Surfaces**: Tasks must keep `FindingResource` compliant with the Action Surface Contract while clarifying owner, assignee, and exception-owner semantics.
|
||||
**Filament UI UX-001**: Tasks must preserve the existing view-first findings detail layout and keep list/detail surfaces operator-first with explicit ownership truth.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story remains independently testable.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Align implementation with the approved responsibility contract and current codebase patterns before editing runtime code.
|
||||
|
||||
- [X] T001 [P] Review the approved contract and story goals in specs/219-finding-ownership-semantics/spec.md, specs/219-finding-ownership-semantics/plan.md, and specs/219-finding-ownership-semantics/contracts/finding-responsibility.openapi.yaml
|
||||
- [X] T002 [P] Inspect the current responsibility surfaces in apps/platform/app/Filament/Resources/FindingResource.php, apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php, and apps/platform/app/Services/Findings/FindingExceptionService.php
|
||||
- [X] T003 [P] Review the existing findings workflow and Filament test patterns in apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php, apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php, and specs/219-finding-ownership-semantics/quickstart.md
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared derived semantics and focused test harness that all user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Implement the shared derived responsibility-state helper in apps/platform/app/Models/Finding.php so owner, assignee, and `orphaned_accountability` state rules come from one local source
|
||||
- [X] T005 [P] Create the focused Filament semantics test scaffold with explicit tenant-context setup in apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php
|
||||
- [X] T006 [P] Create the focused workflow/audit semantics test scaffold in apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php
|
||||
|
||||
**Checkpoint**: Derived responsibility semantics and dedicated test entry points are ready; user story implementation can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Route accountable ownership clearly (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make owner, assignee, and `orphaned accountability` visible on the existing findings list and detail surfaces.
|
||||
|
||||
**Independent Test**: Load the tenant findings list and detail surfaces with owner-only, owner-plus-assignee, assignee-only, same-user, and both-null findings and verify the operator can tell accountability versus active assignment without leaving the resource, including one-interaction owner-work versus assignee-work isolation when personal-work cues are present.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
> **NOTE**: Add or update these tests first and ensure they fail before implementation.
|
||||
|
||||
- [X] T007 [US1] Add list/detail rendering, personal-work filter isolation, and authorization assertions for owner-only, owner-plus-assignee, assignee-only, same-user, both-null, positive access, and deny-as-not-found cases in apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T008 [US1] Update the findings list hierarchy, responsibility summary, and personal-work filter behavior and naming in apps/platform/app/Filament/Resources/FindingResource.php so owner and assignee are explicitly distinct at first scan and can be isolated separately in one interaction each
|
||||
- [X] T009 [US1] Update the finding detail infolist and default-visible ownership sections in apps/platform/app/Filament/Resources/FindingResource.php so responsibility state and owner versus assignee meaning stay explicit on record view
|
||||
- [X] T010 [US1] Align `orphaned accountability` and next-step wording with the derived responsibility rules in apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php
|
||||
|
||||
**Checkpoint**: User Story 1 should now make finding accountability and active assignment understandable from the existing findings resource alone.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Reassign work without losing accountability (Priority: P2)
|
||||
|
||||
**Goal**: Let operators update assignee, owner, or both without ambiguous mutation feedback or audit wording.
|
||||
|
||||
**Independent Test**: Perform responsibility updates on open findings and verify that owner-only, assignee-only, combined, clear-owner, and clear-assignee changes are differentiated in the action flow, response feedback, and audit-facing expectations.
|
||||
|
||||
### Tests for User Story 2 ⚠️
|
||||
|
||||
- [X] T011 [P] [US2] Add owner-only, assignee-only, combined, clear-owner, and clear-assignee change assertions in apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php
|
||||
- [X] T012 [P] [US2] Extend assignment action and service expectations for help text, member validation, explicit `403` versus `404` semantics, and unchanged-role preservation in apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php and apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T013 [US2] Update the row and bulk assignment actions in apps/platform/app/Filament/Resources/FindingResource.php with explicit owner/assignee help text and differentiated success feedback for owner-only, assignee-only, combined, clear-owner, and clear-assignee changes
|
||||
- [X] T014 [US2] Update the responsibility mutation wording and audit-facing classification in apps/platform/app/Services/Findings/FindingWorkflowService.php so assignment changes and clears no longer read as one ambiguous action
|
||||
|
||||
**Checkpoint**: User Story 2 should now preserve the accountability contract when responsibility changes are made from existing findings actions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep exception ownership separate (Priority: P3)
|
||||
|
||||
**Goal**: Preserve a clear boundary between finding owner and exception owner wherever exception context appears on a finding surface.
|
||||
|
||||
**Independent Test**: Open a finding with exception context and verify that finding owner remains distinct from exception owner on the detail surface and in the exception-request flow.
|
||||
|
||||
### Tests for User Story 3 ⚠️
|
||||
|
||||
- [X] T015 [US3] Extend apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php with exception-owner distinction and finding-context exception-request wording coverage
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T016 [US3] Update the risk-governance display and exception-request action copy in apps/platform/app/Filament/Resources/FindingResource.php so `Exception owner` remains separate from finding owner on list/detail/action flows
|
||||
- [X] T017 [US3] Align finding-context exception-request wording and related feedback in apps/platform/app/Services/Findings/FindingExceptionService.php so exception ownership never reuses finding-owner semantics
|
||||
|
||||
**Checkpoint**: User Story 3 should now keep exception governance ownership visibly separate from finding accountability.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final verification, formatting, lane validation, and review readiness across all stories.
|
||||
|
||||
- [X] T018 Reconcile any final lane or proof-depth changes against specs/219-finding-ownership-semantics/plan.md so the documented fast-feedback and confidence strategy still matches the implemented test surface and preserves the explicit `document-in-feature` outcome
|
||||
- [X] T019 Run focused Pest coverage with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php tests/Feature/Findings/FindingWorkflowRowActionsTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||
- [X] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for apps/platform/app/Models/Finding.php, apps/platform/app/Filament/Resources/FindingResource.php, apps/platform/app/Services/Findings/FindingWorkflowService.php, apps/platform/app/Services/Findings/FindingExceptionService.php, and apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php
|
||||
- [X] T021 Validate the final implementation against specs/219-finding-ownership-semantics/quickstart.md and record the active feature PR close-out entry as `Guardrail` with test-governance outcome `document-in-feature`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the MVP.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and is safest after User Story 1 because it refines the same findings resource/action surface.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and User Story 1 because exception-owner distinction builds on the clarified base ownership surface.
|
||||
- **Polish (Phase 6)**: Depends on completion of the desired user stories.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependency on other stories; delivers the core operator truth.
|
||||
- **US2**: Depends on the shared derived responsibility contract from Phase 2 and integrates with the surfaces clarified in US1.
|
||||
- **US3**: Depends on the shared derived responsibility contract from Phase 2 and the explicit owner/assignee rendering from US1.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests should be added or updated first and observed failing before implementation.
|
||||
- The shared `Finding` helper should remain the source for responsibility-state derivation.
|
||||
- Resource surface changes should land before service wording is finalized so copy and behavior stay aligned.
|
||||
- Story-specific verification should complete before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T001, T002, and T003 can run in parallel.
|
||||
- T005 and T006 can run in parallel.
|
||||
- T011 and T012 can run in parallel.
|
||||
- After Phase 2, one contributor can work on US1 surface rendering while another prepares US2 audit semantics tests.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the shared ownership-surface work together after Phase 2:
|
||||
Task: "Add list/detail rendering and authorization assertions in apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php"
|
||||
Task: "Align orphaned accountability and next-step wording in apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch mutation-behavior coverage together:
|
||||
Task: "Add owner-only, assignee-only, and combined change assertions in apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php"
|
||||
Task: "Extend assignment action and service expectations in apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php and apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch exception-owner boundary work together:
|
||||
Task: "Extend apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php with exception-owner distinction coverage"
|
||||
Task: "Align finding-context exception-request wording in apps/platform/app/Services/Findings/FindingExceptionService.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. Validate the clarified owner/assignee semantics on list and detail surfaces
|
||||
5. Stop and review before mutation-flow or exception-boundary refinements if needed
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship the shared derived responsibility contract and visibility semantics in US1
|
||||
2. Add differentiated reassignment feedback and audit wording in US2
|
||||
3. Add exception-owner boundary protection in US3
|
||||
4. Finish with focused Sail tests, Pint, and quickstart/guardrail validation
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple contributors:
|
||||
|
||||
1. One contributor owns `FindingResource` surface clarity for US1
|
||||
2. One contributor prepares the workflow/audit test surfaces for US2
|
||||
3. One contributor prepares the exception-owner wording and verification path for US3
|
||||
4. Recombine for focused regression runs, formatting, and guardrail close-out
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and can be executed in parallel.
|
||||
- User story labels map each task to the corresponding story in spec.md.
|
||||
- No migrations, no new capabilities, and no new abstraction layers are expected.
|
||||
- Preserve explicit tenant-context setup in Filament tests to avoid false routing regressions.
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-20
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass 1 complete.
|
||||
- Required surface-governance metadata such as routes and action-matrix references are present, but the spec avoids implementation mechanics, framework instructions, and code-level solution design.
|
||||
@ -0,0 +1,230 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Governance Operation Run Summaries Contract
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Internal reference contract for Spec 220. These routes continue to return
|
||||
HTML through Filament and Livewire. The vendor media types below document
|
||||
the logical summary payloads that must be derivable before rendering. This
|
||||
is not a public API commitment.
|
||||
paths:
|
||||
/admin/operations:
|
||||
get:
|
||||
summary: Canonical operations list entry point
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered canonical operations list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.governance-operations-list+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceOperationsListPage'
|
||||
'404':
|
||||
description: Workspace context is missing or the viewer is not entitled to the canonical monitoring scope
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Canonical governance operation run detail
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered canonical governance run-detail page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.governance-operation-run-detail+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceOperationRunDetailPage'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks required capability for a related action
|
||||
'404':
|
||||
description: Run is not visible because it does not exist or entitlement is missing
|
||||
components:
|
||||
schemas:
|
||||
GovernanceOperationsListPage:
|
||||
type: object
|
||||
required:
|
||||
- activeContext
|
||||
- rowInspectModel
|
||||
properties:
|
||||
activeContext:
|
||||
type: object
|
||||
properties:
|
||||
workspaceScope:
|
||||
type: string
|
||||
tenantContextActive:
|
||||
type: boolean
|
||||
rowInspectModel:
|
||||
type: string
|
||||
enum:
|
||||
- row_click
|
||||
canonicalDetailRoute:
|
||||
type: string
|
||||
example: /admin/operations/44
|
||||
GovernanceOperationRunDetailPage:
|
||||
type: object
|
||||
required:
|
||||
- runId
|
||||
- canonicalOperationType
|
||||
- summary
|
||||
- diagnosticsAvailable
|
||||
properties:
|
||||
runId:
|
||||
type: integer
|
||||
canonicalOperationType:
|
||||
type: string
|
||||
enum:
|
||||
- baseline.capture
|
||||
- baseline.compare
|
||||
- tenant.evidence.snapshot.generate
|
||||
- tenant.review.compose
|
||||
- tenant.review_pack.generate
|
||||
artifactFamily:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum:
|
||||
- baseline_snapshot
|
||||
- evidence_snapshot
|
||||
- tenant_review
|
||||
- review_pack
|
||||
- null
|
||||
summary:
|
||||
$ref: '#/components/schemas/GovernanceRunDiagnosticSummary'
|
||||
relatedNavigation:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RelatedNavigationLink'
|
||||
diagnosticsAvailable:
|
||||
type: boolean
|
||||
diagnosticsSections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DiagnosticsSection'
|
||||
GovernanceRunDiagnosticSummary:
|
||||
type: object
|
||||
required:
|
||||
- headline
|
||||
- executionOutcomeLabel
|
||||
- artifactImpactLabel
|
||||
- primaryReason
|
||||
- nextActionText
|
||||
properties:
|
||||
headline:
|
||||
type: string
|
||||
executionOutcomeLabel:
|
||||
type: string
|
||||
artifactImpactLabel:
|
||||
type: string
|
||||
primaryReason:
|
||||
type: string
|
||||
affectedScaleCue:
|
||||
$ref: '#/components/schemas/AffectedScaleCue'
|
||||
nextActionText:
|
||||
type: string
|
||||
dominantCause:
|
||||
$ref: '#/components/schemas/DominantCauseBreakdown'
|
||||
secondaryFacts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SummaryFact'
|
||||
DominantCauseBreakdown:
|
||||
type: object
|
||||
required:
|
||||
- primaryLabel
|
||||
- primaryExplanation
|
||||
properties:
|
||||
primaryCode:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
primaryLabel:
|
||||
type: string
|
||||
primaryExplanation:
|
||||
type: string
|
||||
secondaryCauses:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
AffectedScaleCue:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- value
|
||||
- source
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- summary_counts
|
||||
- context
|
||||
- related_artifact_truth
|
||||
confidence:
|
||||
type: string
|
||||
enum:
|
||||
- exact
|
||||
- bounded
|
||||
- best_available
|
||||
SummaryFact:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- value
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
emphasis:
|
||||
type: string
|
||||
enum:
|
||||
- neutral
|
||||
- caution
|
||||
- blocked
|
||||
RelatedNavigationLink:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- visible
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
href:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
visible:
|
||||
type: boolean
|
||||
deniedReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
DiagnosticsSection:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- kind
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- supporting_detail
|
||||
- count_diagnostics
|
||||
- failure_payload
|
||||
- evidence_gap_detail
|
||||
- type_specific_detail
|
||||
collapsedByDefault:
|
||||
type: boolean
|
||||
197
specs/220-governance-run-summaries/data-model.md
Normal file
197
specs/220-governance-run-summaries/data-model.md
Normal file
@ -0,0 +1,197 @@
|
||||
# Data Model: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add or modify persisted domain entities. It adds a logical derived presentation model for canonical governance operation run detail under `/admin/operations/{run}`.
|
||||
|
||||
The design constraint is strict:
|
||||
|
||||
- `OperationRun` remains the only persisted source for run lifecycle and execution truth.
|
||||
- Related artifacts such as `BaselineSnapshot`, `EvidenceSnapshot`, `TenantReview`, and `ReviewPack` remain the persisted source for artifact truth where they exist.
|
||||
- `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` remain the semantic inputs.
|
||||
- The new summary remains fully derived and surface-specific.
|
||||
|
||||
## Existing Persistent Inputs
|
||||
|
||||
### 1. OperationRun
|
||||
|
||||
- Purpose: Canonical operational record for background and governance work.
|
||||
- Key persisted fields used by this feature:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `context`
|
||||
- `summary_counts`
|
||||
- `failure_summary`
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
- Relationships and derived lookups used by this feature:
|
||||
- workspace and tenant context
|
||||
- related artifact resolution through current operation catalog and presenter logic
|
||||
|
||||
### 2. Related Governance Artifacts
|
||||
|
||||
These are not newly modeled by this feature, but they remain relevant when a run produced or references an artifact.
|
||||
|
||||
- `BaselineSnapshot`
|
||||
- `EvidenceSnapshot`
|
||||
- `TenantReview`
|
||||
- `ReviewPack`
|
||||
|
||||
The feature only reads their already-derived truth where available.
|
||||
|
||||
## Existing Derived Inputs
|
||||
|
||||
### A. ArtifactTruthEnvelope
|
||||
|
||||
`ArtifactTruthPresenter` already derives `ArtifactTruthEnvelope` for `OperationRun` and related artifact records.
|
||||
|
||||
Important envelope dimensions already available:
|
||||
|
||||
- `artifactExistence`
|
||||
- `contentState`
|
||||
- `freshnessState`
|
||||
- `publicationReadiness`
|
||||
- `supportState`
|
||||
- `actionability`
|
||||
- `primaryLabel`
|
||||
- `primaryExplanation`
|
||||
- `reason`
|
||||
- `diagnosticLabel`
|
||||
|
||||
This feature must consume that envelope instead of replacing it.
|
||||
|
||||
### B. OperatorExplanationPattern
|
||||
|
||||
`OperatorExplanationBuilder` already derives an explanation pattern containing:
|
||||
|
||||
- `headline`
|
||||
- `evaluationResult`
|
||||
- `executionOutcome`
|
||||
- `trustworthinessLevel`
|
||||
- `reliabilityStatement`
|
||||
- `coverageStatement`
|
||||
- `dominantCauseCode`
|
||||
- `dominantCauseLabel`
|
||||
- `dominantCauseExplanation`
|
||||
- `nextActionCategory`
|
||||
- `nextActionText`
|
||||
- `countDescriptors`
|
||||
|
||||
This feature reuses that pattern as input to the new run-detail summary.
|
||||
|
||||
## Derived Presentation Entities
|
||||
|
||||
### 1. GovernanceRunDiagnosticSummary
|
||||
|
||||
Primary derived object for canonical run detail.
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `headline` | One dominant first-pass statement for the run detail page | derived from `ArtifactTruthEnvelope` + `OperatorExplanationPattern` |
|
||||
| `executionOutcomeLabel` | Technical execution result kept visible as a separate fact | `OperationRun.outcome` via existing badge semantics |
|
||||
| `artifactImpactLabel` | What the resulting artifact means for operator action | artifact truth + explanation pattern |
|
||||
| `primaryReason` | One short reason supporting the headline | dominant cause explanation or primary explanation |
|
||||
| `affectedScaleCue` | One operator-readable scale cue, such as ambiguous subjects or missing sections | `summary_counts`, run `context`, or related artifact truth |
|
||||
| `nextActionText` | First follow-up step the operator should see | existing explanation or next-step logic |
|
||||
| `secondaryCauses[]` | Additional contributing causes preserved below the primary cause | ranked from reason/context inputs |
|
||||
| `diagnosticsAvailable` | Whether deeper technical sections still exist below | derived from reason, payload, or technical sections |
|
||||
|
||||
Validation rules:
|
||||
|
||||
- Exactly one `headline` is allowed for the default-visible summary.
|
||||
- `artifactImpactLabel` must stay distinct from `executionOutcomeLabel`.
|
||||
- `affectedScaleCue` is optional, but when present it must be backed by numeric or enumerated persisted evidence, not freeform guesswork.
|
||||
- `secondaryCauses[]` must not repeat the dominant cause.
|
||||
|
||||
### 2. DominantCauseBreakdown
|
||||
|
||||
Logical grouping of the main and supporting causes for degraded runs.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `primaryCauseCode` | Stable internal reason or derived cause key |
|
||||
| `primaryCauseLabel` | Operator-facing dominant cause label |
|
||||
| `primaryCauseExplanation` | Short explanation shown in the summary area |
|
||||
| `secondaryCauses[]` | Additional causes shown in supporting detail only |
|
||||
| `rankingRule` | Stable ranking rule used to keep ordering deterministic |
|
||||
|
||||
Rules:
|
||||
|
||||
- Ranking must be deterministic for equivalent runs.
|
||||
- The same cause class must keep the same reading direction across covered governance families.
|
||||
- A run with no meaningful secondary cause data may omit the secondary list entirely.
|
||||
|
||||
### 3. AffectedScaleCue
|
||||
|
||||
Small derived object explaining what was affected and at what scale.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `label` | Operator-facing scale label such as `Affected subjects`, `Missing sections`, or `Incomplete dimensions` |
|
||||
| `value` | Human-readable count or scale statement |
|
||||
| `source` | Where the cue came from: `summary_counts`, `context`, or related artifact truth |
|
||||
| `confidence` | Whether the cue is exact, bounded, or best available from persisted context |
|
||||
|
||||
Rules:
|
||||
|
||||
- This object remains optional because not every run family has equally rich scale data.
|
||||
- It must never introduce a new persisted count contract.
|
||||
- It must not imply precision the persisted data does not support.
|
||||
|
||||
### 4. GovernanceRunSummaryContext
|
||||
|
||||
Logical context for the summary builder.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `surface` | Always `canonical_operation_run_detail` for this spec |
|
||||
| `canonicalOperationType` | Canonical operation type from `OperationCatalog` |
|
||||
| `artifactFamily` | Related artifact family when one exists |
|
||||
| `tenantVisibility` | Whether related tenant/artifact context is visible to the current actor |
|
||||
|
||||
Rules:
|
||||
|
||||
- This context is surface-specific and must not become a cross-product taxonomy.
|
||||
- Tenant visibility rules must suppress inaccessible related labels and links.
|
||||
|
||||
## Covered Run Families
|
||||
|
||||
| Canonical Type | Primary Artifact Family | Typical Affected-Scale Source | Dominant-Cause Focus |
|
||||
|---|---|---|---|
|
||||
| `baseline.capture` | `baseline_snapshot` | `summary_counts`, `context.result`, baseline snapshot summary | blocked prerequisite, zero in-scope subjects, unusable snapshot result |
|
||||
| `baseline.compare` | none direct, but linked baseline/evidence truth may exist | `summary_counts`, `context.baseline_compare`, evidence-gap payloads | suppressed output, ambiguous matches, evidence gaps, strategy failure |
|
||||
| `tenant.evidence.snapshot.generate` | `evidence_snapshot` | evidence snapshot summary, completeness state, run counts | stale or incomplete evidence basis, blocked snapshot generation |
|
||||
| `tenant.review.compose` | `tenant_review` | review summary, missing sections, related evidence truth | missing sections, stale evidence, internal-only review outcome |
|
||||
| `tenant.review_pack.generate` | `review_pack` | pack summary, linked review state, generation context | internal-only or blocked pack outcome, source-review limitations |
|
||||
|
||||
## Derivation Rules
|
||||
|
||||
### Summary selection order
|
||||
|
||||
1. Resolve canonical operation type.
|
||||
2. Resolve related artifact truth if present.
|
||||
3. Resolve operator explanation pattern.
|
||||
4. Derive dominant cause and supporting causes.
|
||||
5. Derive affected-scale cue from existing persisted data.
|
||||
6. Build one `GovernanceRunDiagnosticSummary`.
|
||||
7. Render diagnostics below that summary without altering the underlying truth.
|
||||
|
||||
### Zero-output runs
|
||||
|
||||
- If a run completed technically but produced no decision-grade artifact, the summary must explicitly say so.
|
||||
- Zero output must never default to a neutral or green reading.
|
||||
|
||||
### Multi-cause degraded runs
|
||||
|
||||
- One primary cause is required.
|
||||
- Additional causes remain visible as supporting detail only.
|
||||
- The ranking rule must be deterministic and shared across all covered run families.
|
||||
|
||||
### Authorization-sensitive output
|
||||
|
||||
- Related artifact names, tenant names, and links may only appear when entitlement checks already pass.
|
||||
- The summary may remain useful without those labels by using generic operator-safe phrasing.
|
||||
299
specs/220-governance-run-summaries/plan.md
Normal file
299
specs/220-governance-run-summaries/plan.md
Normal file
@ -0,0 +1,299 @@
|
||||
# Implementation Plan: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
**Branch**: `220-governance-run-summaries` | **Date**: 2026-04-20 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing canonical Monitoring run-detail, artifact-truth, and operator-explanation seams. The intended implementation is a bounded derived summary layer for governance operation runs, not a new persistence model, not a new lifecycle/state family, and not a new action or surface framework.
|
||||
|
||||
## Summary
|
||||
|
||||
Add one operator-first diagnostic summary to canonical governance run detail so baseline capture, baseline compare, evidence snapshot generation, tenant review composition, and review-pack generation runs explain dominant artifact impact, dominant cause, affected scale, artifact trustworthiness, and next action before raw diagnostics. The implementation will reuse `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the current enterprise-detail builders, and it will introduce one small `GovernanceRunDiagnosticSummary` value object plus builder under `App\Support\OpsUx` so the canonical detail page can express affected-scale and multi-cause ranking without inventing a broader UI framework.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
|
||||
**Storage**: PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned
|
||||
**Testing**: Pest v4 unit and feature tests, focused Monitoring/Filament/Authorization coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
|
||||
**Project Type**: Laravel monolith web application inside the `wt-plattform` monorepo
|
||||
**Performance Goals**: Preserve DB-only render behavior on canonical run detail, add no render-time external calls, avoid new query breadth, and keep first-pass operator comprehension inside a 10-15 second scan window
|
||||
**Constraints**: No new Graph calls, no new routes, no new `OperationRun` statuses or outcomes, no new `summary_counts` keys, no new notification surfaces, no new destructive actions, no cross-tenant leakage, and no duplication between decision summary and existing banners
|
||||
**Scale/Scope**: One canonical Monitoring detail surface, five governance run families, one bounded derived summary seam, and focused regression coverage for summary ordering, multi-cause explanation, zero-output runs, and authorization safety
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + existing Monitoring detail primitives
|
||||
- **Shared-family relevance**: governance run-detail family, operator explanation family, enterprise detail family
|
||||
- **State layers in scope**: page, detail, URL-query
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: monitoring-state-page
|
||||
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
|
||||
- **Exception path and spread control**: retain the existing diagnostic-detail exception on canonical run detail; do not spread it into new surfaces or action patterns
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | The feature reorders explanation on existing run and artifact truth only; inventory and snapshot ownership remain unchanged |
|
||||
| Read/write separation | PASS | PASS | No new writes, previews, confirmations, or audit-log paths are introduced |
|
||||
| Graph contract path | PASS | PASS | No new Graph calls or contract-registry changes |
|
||||
| RBAC / workspace / tenant isolation | PASS | PASS | Canonical `/admin/operations/{run}` remains tenant-safe; non-members stay `404`; in-scope capability denials remain `403` |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` lifecycle, feedback surfaces, initiator rules, and summary-count contracts remain unchanged |
|
||||
| Ops-UX summary counts | PASS | PASS | Existing flat numeric `summary_counts` stay canonical; the new summary only interprets them |
|
||||
| Proportionality / no premature abstraction | PASS | PASS | One bounded run-summary helper is justified; no new framework, persistence, or state family is needed |
|
||||
| Few layers / UI semantics | PASS | PASS | New logic stays downstream of `ArtifactTruthEnvelope` and `OperatorExplanationPattern`; no second truth source is introduced |
|
||||
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge domains remain canonical; the feature changes order and supporting copy only |
|
||||
| Filament-native UI (UI-FIL-001) | PASS | PASS | Existing Filament detail page, sections, and enterprise-detail builders remain the implementation path |
|
||||
| Action surface / inspect model | PASS | PASS | Canonical run detail remains the single inspect model; no new row, header, or bulk actions are introduced |
|
||||
| Decision-first / OPSURF | PASS | PASS | The page remains a Tertiary Evidence / Diagnostics Surface, but its first read becomes operator-first |
|
||||
| Test governance (TEST-GOV-001) | PASS | PASS | Proof stays in focused Monitoring feature coverage plus one narrow unit seam; no browser or heavy-governance expansion |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The work stays entirely within the current Filament v5 + Livewire v4 stack |
|
||||
| Provider registration / global search / assets | PASS | PASS | No panel/provider changes, `OperationRunResource` stays non-searchable, and no new assets are required |
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Feature` for canonical Monitoring run detail and authorization behavior; `Unit` only for the bounded run-summary builder or ranking helper if introduced
|
||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The feature is proven by operator-visible hierarchy, dominant-cause ordering, zero-output handling, and tenant-safe canonical run detail. That needs focused surface tests plus one narrow unit seam, not browser or heavy-governance breadth.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
|
||||
- `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
- `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Shared fixture drift is the main risk. `BuildsGovernanceArtifactTruthFixtures` must stay opt-in, and any multi-cause seeded run helper should remain local to the Monitoring suite instead of becoming a repo-wide default.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; all new scenario builders must require explicit run type, outcome, reason codes, and related artifact context
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `monitoring-state-page` coverage is required; existing `standard-native-filament` relief is not enough for summary-order assertions on the canonical detail page
|
||||
- **Closing validation and reviewer handoff**: Reviewers must confirm summary-first order, no duplicate dominant-cause copy across banners and decision zone, zero-output runs staying non-green, cross-family consistency for shared cause classes, and `404` vs `403` semantics on the canonical route.
|
||||
- **Budget / baseline / trend follow-up**: Low-to-moderate assertion growth within Monitoring and one new focused suite; no lane-budget follow-up expected unless helper sprawl begins
|
||||
- **Review-stop questions**: Does the change stay inside current Monitoring detail seams? Did any new summary helper become broader than this surface needs? Did shared fixtures remain opt-in? Did any touched view leak inaccessible tenant or artifact hints?
|
||||
- **Escalation path**: document-in-feature unless a second shared semantic layer, new persistence, or broad fixture default is proposed; then reject-or-split
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: The expected suite cost and abstraction surface stay tightly bounded to one existing canonical detail page and its current governance run families
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/220-governance-run-summaries/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── governance-run-summaries.logical.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── Operations/
|
||||
│ │ │ └── TenantlessOperationRunViewer.php
|
||||
│ │ └── Resources/
|
||||
│ │ └── OperationRunResource.php
|
||||
│ └── Support/
|
||||
│ ├── OpsUx/
|
||||
│ │ ├── OperationUxPresenter.php
|
||||
│ │ ├── SummaryCountsNormalizer.php
|
||||
│ │ ├── GovernanceRunDiagnosticSummary.php
|
||||
│ │ └── GovernanceRunDiagnosticSummaryBuilder.php
|
||||
│ ├── ReasonTranslation/
|
||||
│ │ └── ReasonPresenter.php
|
||||
│ └── Ui/
|
||||
│ ├── EnterpriseDetail/
|
||||
│ ├── GovernanceArtifactTruth/
|
||||
│ │ └── ArtifactTruthPresenter.php
|
||||
│ └── OperatorExplanation/
|
||||
│ ├── OperatorExplanationBuilder.php
|
||||
│ └── OperatorExplanationPattern.php
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── filament/
|
||||
│ └── pages/
|
||||
│ └── operations/
|
||||
│ └── tenantless-operation-run-viewer.blade.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Authorization/
|
||||
│ │ └── OperatorExplanationSurfaceAuthorizationTest.php
|
||||
│ ├── Monitoring/
|
||||
│ │ ├── ArtifactTruthRunDetailTest.php
|
||||
│ │ ├── GovernanceOperationRunSummariesTest.php
|
||||
│ │ └── GovernanceRunExplanationFallbackTest.php
|
||||
│ ├── Filament/
|
||||
│ │ └── OperationRunBaselineTruthSurfaceTest.php
|
||||
│ └── RunAuthorizationTenantIsolationTest.php
|
||||
└── Unit/
|
||||
├── Support/
|
||||
│ ├── OpsUx/
|
||||
│ │ └── GovernanceRunDiagnosticSummaryBuilderTest.php
|
||||
│ └── OperatorExplanation/
|
||||
│ └── OperatorExplanationBuilderTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith. The work stays concentrated in the current Monitoring detail files, existing `Support/OpsUx` and UI helper seams, and focused Pest suites. No new base directory, panel, or package is needed.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Bounded run-summary helper/value object | Needed to keep dominant-cause ranking, affected-scale mapping, and summary ordering out of `OperationRunResource` and page templates | Extending the resource/page inline would bury operation-family logic in Filament schema code and make regression coverage brittle |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Canonical governance run detail is still too technical for first-pass operator decisions, especially when execution succeeded but artifact usability did not, or when several degraded causes exist together.
|
||||
- **Existing structure is insufficient because**: Existing badges, explanation patterns, and raw payload sections require operators to synthesize impact, trust, and next action themselves. The missing piece is a first-pass run-detail summary that ranks cause and scale for this single surface.
|
||||
- **Narrowest correct implementation**: Add one run-detail-specific summary object and builder inside `Support/OpsUx`, derived entirely from `OperationRun`, `ArtifactTruthEnvelope`, `OperatorExplanationPattern`, and existing count/context payloads.
|
||||
- **Ownership cost created**: One small builder/value-object pair, one local set of dominance rules, and focused Monitoring/unit tests.
|
||||
- **Alternative intentionally rejected**: Page-local copy patches and ad-hoc Filament facts only. That would duplicate operation-type logic, make hierarchy drift likely, and fail to protect cross-family consistency.
|
||||
- **Release truth**: Current-release truth. This plan improves an existing trust surface now rather than preparing a future platform abstraction.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Keep canonical Monitoring run detail on `OperationRunResource` + `TenantlessOperationRunViewer`; do not create a second run-detail page.
|
||||
- Treat `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` as the canonical semantic inputs.
|
||||
- Introduce one bounded `GovernanceRunDiagnosticSummary` seam so the decision zone can express affected scale, dominant-cause ranking, and secondary-cause detail without overloading the Filament resource schema.
|
||||
- Derive affected-scale cues from existing `summary_counts`, run `context`, and related artifact metadata; do not add schema or `summary_counts` contract changes.
|
||||
- Keep lifecycle/context banners specialized and let the decision zone own the dominant explanation to avoid duplicated operator copy.
|
||||
- Extend current Monitoring and authorization suites and keep multi-cause fixture helpers local or opt-in.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/`:
|
||||
|
||||
- `research.md`: implementation-seam decisions, risks, and rejected alternatives
|
||||
- `data-model.md`: logical model for the derived governance run summary, dominant-cause breakdown, and affected-scale cues
|
||||
- `contracts/governance-run-summaries.logical.openapi.yaml`: internal logical contract for canonical operations list/detail rendering requirements
|
||||
- `quickstart.md`: focused verification workflow for manual and automated validation
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required; all summary state remains derived.
|
||||
- The primary implementation seam is canonical run detail plus a small helper under `App\Support\OpsUx`, not a new cross-domain UI framework.
|
||||
- Existing Filament action topology, route shape, authorization behavior, and destructive-action semantics remain unchanged.
|
||||
- `OperationUxPresenter` remains the façade for memoized governance explanation state on run detail.
|
||||
- Existing technical sections such as count diagnostics, failure payloads, evidence-gap detail, and artifact-truth detail remain available but must become secondary to the new summary block.
|
||||
|
||||
## Phase 1 Agent Context Update
|
||||
|
||||
Run:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Constitution Check — Post-Design Re-evaluation
|
||||
|
||||
- PASS — the design remains read-surface focused and does not introduce new write paths, Graph calls, assets, or authorization semantics.
|
||||
- PASS — Livewire v4.0+ and Filament v5 constraints remain unchanged, no provider registration move is required, `OperationRunResource` remains non-searchable, and no new destructive actions or assets are introduced.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Introduce One Bounded Governance Run Summary Seam
|
||||
|
||||
**Goal**: Derive one operator-first run-detail summary without creating a second truth source.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php` | Add a small value object carrying headline, dominant cause, affected scale, trust statement, secondary causes, and next action |
|
||||
| A.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Derive the summary from `OperationRun`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `summary_counts`, and run context |
|
||||
| A.3 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Reuse existing memoization to expose the summary on canonical run detail without adding a new cache family |
|
||||
|
||||
### Phase B — Rewire Canonical Run Detail Around The First Decision
|
||||
|
||||
**Goal**: Make the decision zone lead with humanized diagnostic summaries and push raw diagnostics down.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Filament/Resources/OperationRunResource.php` | Update the enterprise-detail decision zone to render the new summary, affected-scale cue, and processing-versus-artifact split ahead of technical sections |
|
||||
| B.2 | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep scope, lifecycle, and restore banners specialized while removing duplicated dominant-cause copy from banner-level messaging |
|
||||
| B.3 | `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` or existing enterprise-detail view partials | Ensure the default reading order is summary first, supporting facts second, diagnostics third |
|
||||
|
||||
### Phase C — Add Stable Rules For Covered Governance Run Families
|
||||
|
||||
**Goal**: Keep summary language and affected-scale cues stable across the five scoped governance families.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add baseline-capture rules for blocked prerequisite, zero-subject capture, and unusable snapshot outcomes |
|
||||
| C.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add baseline-compare rules for suppressed output, ambiguous matches, evidence gaps, and strategy failures |
|
||||
| C.3 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add evidence snapshot, tenant review, and review-pack generation rules using existing related artifact truth plus run context |
|
||||
| C.4 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add one stable dominant-cause ranking rule so tied degraded runs do not reorder arbitrarily between renders |
|
||||
|
||||
### Phase D — Preserve Tenant Safety, Related Links, and Existing Action Topology
|
||||
|
||||
**Goal**: Improve explanation without changing route, RBAC, or action behavior.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep canonical back-link lineage, active-tenant continuity, and grouped related navigation intact |
|
||||
| D.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Ensure summary output suppresses inaccessible artifact or tenant hints when related navigation is not allowed |
|
||||
| D.3 | Existing authorization tests and related-link helpers | Keep `404` vs `403` semantics unchanged and verify no new mutation affordances appear |
|
||||
|
||||
### Phase E — Protect The Surface With Focused Regression Coverage
|
||||
|
||||
**Goal**: Add the smallest test set that locks summary order, multi-cause behavior, zero-output runs, and authorization safety.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` | Add unit coverage for dominant-cause ranking, affected-scale derivation, and next-step category mapping |
|
||||
| E.2 | `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` | Add end-to-end run-detail coverage for multi-cause degraded runs, all-zero runs, cross-family parity, and diagnostics-secondary ordering |
|
||||
| E.3 | `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` and `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php` | Update existing assertions to match final summary-first wording and remove brittle duplication gaps |
|
||||
| E.4 | `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` and `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php` | Extend canonical route coverage for tenant-safe summary rendering and inaccessible related navigation |
|
||||
| E.5 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Run formatting and the narrowest proving commands before implementation close-out |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — Canonical run detail remains the only detailed run-inspection surface
|
||||
|
||||
The feature improves the current canonical Monitoring detail page instead of creating a second run viewer or a special governance-only route.
|
||||
|
||||
### D-002 — Existing truth and explanation envelopes remain canonical
|
||||
|
||||
`ArtifactTruthEnvelope` and `OperatorExplanationPattern` remain the semantic source of truth. The new summary layer only ranks and presents them for this one surface.
|
||||
|
||||
### D-003 — Affected scale stays derived from existing persisted signals
|
||||
|
||||
`summary_counts`, run `context`, failure summaries, and related artifact truth are sufficient inputs. The plan explicitly avoids schema changes or new count contracts.
|
||||
|
||||
### D-004 — Banners stay specialized; the decision zone owns the main explanation
|
||||
|
||||
Context, lifecycle, or restore-continuation banners may still appear, but the dominant cause and next-step explanation must live in the decision zone so the page does not say the same thing twice.
|
||||
|
||||
### D-005 — Shared fixtures stay opt-in
|
||||
|
||||
Multi-cause or zero-output scenario builders should remain local to the Monitoring suite unless a second real consumer proves they belong in a shared concern.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| The new summary duplicates existing banner copy and makes the page louder instead of calmer | High | Medium | Keep banners specialized and let the decision zone own dominant explanation text |
|
||||
| Dominant-cause ranking changes arbitrarily between equivalent multi-cause runs | High | Medium | Encode one explicit ranking rule and cover it with unit tests plus one multi-cause feature test |
|
||||
| Affected-scale cues drift by operation family and become inconsistent | Medium | Medium | Centralize scale mapping in the builder and reuse it across all covered run families |
|
||||
| Shared fixtures or helper defaults silently hide required run context | Medium | Medium | Require explicit type, outcome, reason, and related artifact context in new scenario builders |
|
||||
| Summary copy leaks inaccessible tenant or artifact hints on canonical `/admin` routes | High | Low | Keep authorization tests on related links and summary rendering together and suppress inaccessible context |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Add one new focused feature suite for governance run summaries and keep it scoped to canonical Monitoring run detail.
|
||||
- Add one narrow unit suite for dominant-cause and affected-scale derivation only if a dedicated builder is introduced.
|
||||
- Reuse existing Monitoring and authorization suites for regression coverage instead of creating browser or heavy-governance breadth.
|
||||
- Keep `BuildsGovernanceArtifactTruthFixtures` opt-in and add any multi-cause builder locally to the Monitoring suite first.
|
||||
- Preserve DB-only rendering guarantees on canonical run detail while adjusting the visible summary hierarchy.
|
||||
147
specs/220-governance-run-summaries/quickstart.md
Normal file
147
specs/220-governance-run-summaries/quickstart.md
Normal file
@ -0,0 +1,147 @@
|
||||
# Quickstart: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that canonical governance operation run detail now answers the first operator question with one dominant summary, one short reason, one affected-scale cue where available, and one next step, while keeping raw diagnostics secondary and preserving current authorization and navigation semantics.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail if it is not already running.
|
||||
2. Ensure the acting user is a valid workspace member and is entitled to the target tenant where the run is tenant-bound.
|
||||
3. Prepare representative runs for these cases:
|
||||
- blocked baseline capture with no usable inventory basis
|
||||
- baseline compare with ambiguous matches or evidence gaps
|
||||
- evidence snapshot generation with stale or incomplete output
|
||||
- tenant review composition with missing sections or stale evidence
|
||||
- review-pack generation with internal-only or blocked outcome
|
||||
- one multi-cause degraded run
|
||||
- one zero-output or all-zero run that must not read as green
|
||||
|
||||
## Focused Automated Verification
|
||||
|
||||
Run formatting first:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Then run the smallest proving set:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact \
|
||||
tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php \
|
||||
tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php \
|
||||
tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php \
|
||||
tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php \
|
||||
tests/Feature/RunAuthorizationTenantIsolationTest.php \
|
||||
tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php \
|
||||
tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php
|
||||
```
|
||||
|
||||
If the new focused suite is not yet isolated, run the Monitoring subset instead:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/
|
||||
```
|
||||
|
||||
## Manual Validation Pass
|
||||
|
||||
### 1. Canonical run detail entry path
|
||||
|
||||
Open `/admin/operations` and drill into a governance run.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- row navigation remains the inspect model,
|
||||
- no new row or header action appears,
|
||||
- and arriving from tenant context does not silently widen back to all-tenant semantics.
|
||||
|
||||
### 2. Baseline capture blocked by prerequisite
|
||||
|
||||
Open a blocked baseline-capture run.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- the page leads with `no baseline was captured`-style meaning,
|
||||
- the missing prerequisite appears before raw payloads,
|
||||
- execution status and artifact usability are visible as separate facts,
|
||||
- and raw diagnostics remain lower on the page.
|
||||
|
||||
### 3. Baseline compare with ambiguity or suppressed output
|
||||
|
||||
Open a baseline-compare run with evidence gaps, ambiguous matches, or suppressed output.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- the first summary names the compare outcome and its trust limitation,
|
||||
- the dominant cause is understandable without raw JSON,
|
||||
- any affected-scale cue is visible when supported by stored counts or gap detail,
|
||||
- and `0 findings` or zero-output does not read as an all-clear.
|
||||
|
||||
### 4. Evidence snapshot generation
|
||||
|
||||
Open a run that produced stale or incomplete evidence.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- processing success does not imply trustworthy evidence,
|
||||
- the page states the evidence limitation before technical payloads,
|
||||
- and next-step guidance points to the right recovery action.
|
||||
|
||||
### 5. Tenant review composition and review-pack generation
|
||||
|
||||
Open one review-compose run and one review-pack-generation run.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- review generation can explain missing sections or stale evidence without JSON,
|
||||
- pack generation can explain internal-only or blocked shareability outcomes,
|
||||
- and related artifact links remain available only when the actor is entitled to them.
|
||||
|
||||
### 6. Multi-cause degraded run
|
||||
|
||||
Open a run with two or more stored degraded causes.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- one dominant cause is shown first,
|
||||
- at least one secondary cause is still discoverable,
|
||||
- and the ordering is stable across reloads.
|
||||
|
||||
### 7. Cross-family parity
|
||||
|
||||
Open two covered governance runs from different families that share the same dominant cause class.
|
||||
|
||||
Confirm that:
|
||||
|
||||
- the same cause class keeps the same primary reading direction,
|
||||
- the next-step category stays consistent where the persisted truth supports the same operator action,
|
||||
- and cross-family wording does not drift into conflicting operator guidance.
|
||||
|
||||
### 8. Authorization and tenant safety
|
||||
|
||||
Confirm that:
|
||||
|
||||
- non-members still receive deny-as-not-found behavior,
|
||||
- in-scope members lacking capability still receive `403` where expected,
|
||||
- summary text does not leak inaccessible tenant or artifact hints,
|
||||
- and `OperationRun` remains non-searchable.
|
||||
|
||||
### 9. Ten-second scan check
|
||||
|
||||
Timebox the first visible scan of one blocked, one degraded, and one zero-output governance run detail page.
|
||||
|
||||
Confirm that within 10-15 seconds an operator can determine:
|
||||
|
||||
- what happened,
|
||||
- whether the resulting artifact is trustworthy enough to act on,
|
||||
- what was affected when the stored data supports that cue,
|
||||
- and what the next step is,
|
||||
|
||||
without opening diagnostic sections.
|
||||
|
||||
## Final Verification Notes
|
||||
|
||||
- Keep diagnostics present but secondary.
|
||||
- Do not add retry, cancel, force-fail, or other intervention controls as part of this slice.
|
||||
- If a manual reviewer sees the same dominant-cause copy both in a banner and in the decision zone, treat that as a regression and tighten the summary ownership.
|
||||
49
specs/220-governance-run-summaries/research.md
Normal file
49
specs/220-governance-run-summaries/research.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Research: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
## Decision 1: Keep canonical governance run detail on the existing Monitoring viewer and detail resource
|
||||
|
||||
- **Decision**: Reuse `OperationRunResource` and `TenantlessOperationRunViewer` as the single canonical run-detail surface for Spec 220 instead of creating a new governance-only viewer.
|
||||
- **Rationale**: The repo already routes canonical Monitoring run detail through these seams and already has the right RBAC, action-surface, and navigation guardrails in place. The problem is explanation order and summary quality, not missing routing or missing surface ownership.
|
||||
- **Alternatives considered**:
|
||||
- Create a second governance-specific run-detail page. Rejected because it would duplicate route ownership, action hierarchy, and authorization semantics for one existing surface.
|
||||
- Add page-local partials only in the Blade template. Rejected because the run-detail summary needs stable derivation rules, not just another rendering layer.
|
||||
|
||||
## Decision 2: Treat `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` as the canonical semantic inputs
|
||||
|
||||
- **Decision**: Build the new summary from the existing `ArtifactTruthEnvelope`, `OperatorExplanationPattern`, and reason-translation envelopes instead of introducing a second semantic source.
|
||||
- **Rationale**: The repo already derives artifact truth and operator explanation for `OperationRun` records, including governance families like `baseline.capture`, `baseline.compare`, `tenant.evidence.snapshot.generate`, `tenant.review.compose`, and `tenant.review_pack.generate`. Reusing that chain preserves existing truth ownership and keeps the new work downstream and bounded.
|
||||
- **Alternatives considered**:
|
||||
- Add a new persisted summary state to `operation_runs`. Rejected because the desired summary is fully derivable from current persisted truth and would create drift risk.
|
||||
- Put all summary logic directly inside `OperationRunResource`. Rejected because it would bury operation-family rules inside Filament schema code and make tests brittle.
|
||||
|
||||
## Decision 3: Add one bounded `GovernanceRunDiagnosticSummary` seam only if affected-scale and dominant-cause rules cannot stay in the current presenter flow
|
||||
|
||||
- **Decision**: If the current detail seams cannot cleanly express dominant cause, affected scale, and secondary-cause breakdown, add one small value object plus builder under `App\Support\OpsUx` and expose it through `OperationUxPresenter`.
|
||||
- **Rationale**: Spec 220 needs more than current badges and explanation labels. It needs one stable first-pass summary, especially for multi-cause degraded runs and all-zero runs. A small run-detail-specific helper is justified because the work is limited to one existing surface and several real operation families already consume the same route.
|
||||
- **Alternatives considered**:
|
||||
- Extend `ArtifactTruthPresenter` to own all run-detail ranking logic. Rejected because artifact truth is broader than this one run-detail question and should remain canonical truth, not surface-specific emphasis logic.
|
||||
- Build a generic cross-product explanation framework. Rejected because the spec is explicitly scoped to canonical governance run detail.
|
||||
|
||||
## Decision 4: Derive affected-scale cues from existing `summary_counts`, run context, and related artifact truth
|
||||
|
||||
- **Decision**: Affected scale must come from existing persisted signals such as `summary_counts`, known run-context payloads, failure summaries, and related artifact summaries. No schema change or count-contract expansion is planned.
|
||||
- **Rationale**: Covered operation families already persist enough context to support statements like ambiguous subject matches, missing sections, partial evidence dimensions, or zero captured subjects. The missing work is ranking and presenting those signals consistently.
|
||||
- **Alternatives considered**:
|
||||
- Add new operation-specific summary fields or nested count structures. Rejected because Ops-UX already constrains `summary_counts` to flat numeric keys, and the feature does not need new persistence.
|
||||
- Omit affected-scale cues entirely. Rejected because the spec explicitly requires the page to explain what was affected, not just why it failed.
|
||||
|
||||
## Decision 5: Keep banners specialized and let the decision zone own the dominant explanation
|
||||
|
||||
- **Decision**: Existing canonical context, lifecycle, blocked-execution, and restore-continuation banners remain specialized. The main humanized summary must live in the decision zone so the page does not duplicate dominant-cause copy.
|
||||
- **Rationale**: The current run detail already has banner-level messaging. Adding another banner or repeating the same explanation in two places would increase attention load instead of reducing it. The summary should become the first read inside the decision zone, with banners reserved for scope, stale lifecycle, and special restore continuity contexts.
|
||||
- **Alternatives considered**:
|
||||
- Add a new top-of-page summary banner. Rejected because it would compete with existing lifecycle and context banners.
|
||||
- Remove existing banners entirely. Rejected because they already communicate valid scope or lifecycle information outside the core diagnostic summary.
|
||||
|
||||
## Decision 6: Extend current Monitoring and authorization suites and keep multi-cause fixtures local first
|
||||
|
||||
- **Decision**: Reuse existing Monitoring, Filament, and authorization suites; add one new focused `GovernanceOperationRunSummariesTest` plus one narrow unit seam if a builder is introduced. Keep multi-cause fixture builders local to the Monitoring suite unless another consumer emerges.
|
||||
- **Rationale**: The repo already has substantial run-detail coverage, including hierarchy assertions, artifact-truth rendering, and `404` vs `403` semantics. The main gaps are multi-cause degraded runs, all-zero runs, and cross-family consistency. Those gaps can be covered without creating a new heavy or browser test family.
|
||||
- **Alternatives considered**:
|
||||
- Rely mainly on browser tests. Rejected because the current feature is better proven through existing Livewire and feature suites.
|
||||
- Move multi-cause builders into shared fixture concerns immediately. Rejected because only Spec 220 currently needs those seeds and shared defaults would be risky.
|
||||
238
specs/220-governance-run-summaries/spec.md
Normal file
238
specs/220-governance-run-summaries/spec.md
Normal file
@ -0,0 +1,238 @@
|
||||
# Feature Specification: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
**Feature Branch**: `220-governance-run-summaries`
|
||||
**Created**: 2026-04-20
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Humanized Diagnostic Summaries for Governance Operations"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Governance operation run-detail pages already carry correct outcome, reason, and artifact-truth semantics, but the first useful explanation still often lives in raw JSON or low-level diagnostic sections.
|
||||
- **Today's failure**: An operator can open a run that reads `Completed with follow-up`, `Partial`, or `Blocked` and still has to infer the real business meaning: what was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is trustworthy enough to act on.
|
||||
- **User-visible improvement**: Governance run detail leads with one human-readable summary that explains impact, dominant cause, artifact trustworthiness, and next action before any raw diagnostics.
|
||||
- **Smallest enterprise-capable version**: Add one bounded humanized summary layer to canonical governance run detail only, reusing existing outcome taxonomy, reason translation, artifact-truth semantics, and explanation patterns without changing persistence, lifecycle ownership, or action inventory.
|
||||
- **Explicit non-goals**: No operations-list redesign, no dashboard overhaul, no new persistence for summaries, no removal of raw JSON, no new remediation controls on run detail, and no generalized rewrite of every governance artifact page.
|
||||
- **Permanent complexity imported**: One derived governance-run summary contract, one dominant-cause presentation rule set for multi-cause degraded runs, and focused regression coverage for cross-family consistency.
|
||||
- **Why now**: The roadmap marks this as the next open adoption slice after Spec 214. Specs 156, 157, 158, 161, and 214 already established the language and truth model; leaving run detail technical would keep a core trust surface lagging behind the foundation work.
|
||||
- **Why not local**: A page-local copy cleanup would recreate divergent run-detail dialects across baseline, evidence, review, and review-pack governance runs and would not reliably separate processing success from artifact usability.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: One red flag: a reusable guidance pattern across multiple governance run families. It remains acceptable because the scope is restricted to one existing canonical detail surface and does not add new persisted truth, new states, or a cross-product framework.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**: `/admin/operations`, `/admin/operations/{run}`
|
||||
- **Data Ownership**: Tenant-bound governance `OperationRun` records remain tenant-owned operational artifacts exposed through the canonical Monitoring route. Related baseline snapshots can stay workspace-owned, while evidence snapshots, tenant reviews, and review packs remain tenant-owned. This feature changes interpretation and ordering on the canonical run-detail surface only.
|
||||
- **RBAC**: Workspace membership is required for Monitoring access. Tenant entitlement is still required before revealing tenant-bound governance runs or related artifact links from the canonical route. Existing monitoring-view and related-artifact authorization rules remain authoritative. Non-members or non-entitled users remain deny-as-not-found. Members who can reach Monitoring but lack an existing related action permission remain authorization failures for that action.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: When a user reaches Monitoring from an active tenant context, the operations list and related links continue to preserve that tenant context. Opening a governance run detail must not silently broaden the operator back to all tenants.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Humanized summaries, dominant-cause labels, affected-scale cues, and related artifact links are only rendered after workspace and tenant entitlement checks succeed for the referenced run. Inaccessible tenant-bound runs and related records behave as not found and must not leak artifact names, tenant names, or result hints.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Canonical Monitoring operation run detail for governance operations | yes | Native Filament + existing Monitoring detail primitives | shared governance run-detail family | detail, summary hierarchy, diagnostics hierarchy | yes | Existing diagnostic-surface exception remains; this slice only makes the first read operator-safe |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Canonical Monitoring operation run detail for governance operations | Tertiary Evidence / Diagnostics Surface | After drilling in from a baseline, evidence, review, or pack workflow, the operator needs to understand what actually happened and what to do next | Dominant artifact impact, dominant cause, affected scale, processing-versus-artifact split, and next action | Raw JSON, complete reason-code detail, provider payloads, low-level counters, and full multi-cause evidence | Not primary because operators should usually arrive here after another surface already identified the case; this page is the deep explanation layer | Follows drill-in from governance artifact and Monitoring workflows instead of becoming a new queue | Removes the need to read badges and raw JSON before understanding the real problem |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Canonical Monitoring operation run detail for governance operations | Record / Detail / Actions | Canonical diagnostic detail | Open the related artifact or return to the source workflow with the correct next step | Explicit operation-run detail page | forbidden | Existing related navigation remains in header or contextual detail sections | none | /admin/operations | /admin/operations/{run} | Workspace context, active tenant context when present, related artifact type, run family | Operation runs / Operation run | Dominant artifact impact, dominant cause, affected scale, and next action before raw diagnostics | diagnostic_exception - canonical run detail remains the deepest evidence surface, so raw diagnostics stay present, but they must no longer lead the page |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Canonical Monitoring operation run detail for governance operations | Workspace manager or entitled tenant operator | Diagnose why a governance run produced a trustworthy, limited, blocked, or unusable artifact and decide the correct follow-up | Canonical detail | What happened, how much was affected, can I trust the resulting artifact, and what should I do next? | Dominant artifact-impact statement, dominant cause, affected scale, processing-versus-artifact split, next-step guidance, and related artifact context | Raw JSON, full reason-code inventory, provider payloads, low-level counters, and complete multi-cause diagnostics | execution outcome, artifact usability, completeness or reliability, dominant cause, actionability | None on this page; any linked mutations keep their original mutation scopes on their native surfaces | Open related artifact, inspect diagnostics | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Run detail is semantically correct but still too technical for first-pass operator decisions, which allows false-green or ambiguous readings on a core governance troubleshooting surface.
|
||||
- **Existing structure is insufficient because**: Existing badges, reason translation, and raw diagnostic payloads still force operators to synthesize impact, trust, and next action themselves. Local copy tweaks would drift by run family and would not reliably separate execution throughput from artifact trustworthiness.
|
||||
- **Narrowest correct implementation**: Add one bounded summary contract for governance operation run detail only, derived from the existing truth and explanation foundations, while preserving all diagnostics beneath it.
|
||||
- **Ownership cost**: Ongoing maintenance of one shared summary mapping, one stable dominant-cause breakdown rule set, and focused regression coverage for the covered governance run families.
|
||||
- **Alternative intentionally rejected**: Per-page copy patches and a broader operations redesign. The first is too weak and inconsistent; the second is unnecessary for the current operator problem.
|
||||
- **Release truth**: Current-release truth. This spec makes an existing trust surface readable now instead of preparing a future architecture layer.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: The change is proven by what operators see on the canonical Monitoring run-detail page. Focused feature coverage over seeded governance run scenarios is sufficient to prove explanation hierarchy, cause breakdown, and authorization safety without introducing browser or heavy-governance breadth.
|
||||
- **New or expanded test families**: Expand Monitoring feature coverage for governance run detail across baseline capture, baseline compare, evidence snapshot generation (`tenant.evidence.snapshot.generate`), tenant review composition (`tenant.review.compose`), and review-pack generation (`tenant.review_pack.generate`). Add one positive and one negative authorization case for tenant-bound governance runs on the canonical route.
|
||||
- **Fixture / helper cost impact**: Low-to-moderate. Tests can reuse existing workspace, tenant, entitlement, and `OperationRun` setup, but need explicit seeded cases where execution outcome and artifact usability diverge, plus multi-cause degraded runs.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: monitoring-state-page
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions that summary-first hierarchy appears before raw diagnostics and that multi-cause degraded runs stay human-readable.
|
||||
- **Reviewer handoff**: Reviewers must confirm that run detail leads with one dominant explanation, that processing success never reads as automatic artifact success, that raw JSON remains secondary, that a positive and negative authorization case exist, and that the proof stays inside focused Monitoring feature coverage.
|
||||
- **Budget / baseline / trend impact**: Low increase in Monitoring feature assertions only; no new heavy or browser baseline is expected.
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Understand the dominant problem fast (Priority: P1)
|
||||
|
||||
An operator opens a governance run detail page and needs to understand the dominant problem and next step without reading raw JSON.
|
||||
|
||||
**Why this priority**: This is the core trust outcome. If the first read remains technical, the feature has not delivered its value.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening seeded governance runs on the canonical Monitoring detail route and verifying that an operator can identify what happened and what to do next from the default-visible summary alone.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline compare run completed with follow-up because subject matching was ambiguous, **When** an operator opens the run detail page, **Then** the page states that the compare finished but the result is only partially trustworthy, names ambiguous matching as the dominant cause, and points the operator to scope review before any raw diagnostics.
|
||||
2. **Given** a baseline capture run is blocked because no usable inventory basis exists, **When** an operator opens the run detail page, **Then** the page states that no baseline was captured, explains the missing prerequisite, and points to the prerequisite action before any raw JSON.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Separate processing success from artifact trust (Priority: P2)
|
||||
|
||||
An operator needs technically successful processing counts to remain visibly separate from whether the resulting artifact is usable, shareable, or decision-grade.
|
||||
|
||||
**Why this priority**: False-green interpretations come from execution success reading like artifact success.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing governance runs where processing completed but the resulting artifact stayed stale, limited, internal-only, or otherwise not decision-grade.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an evidence snapshot generation run processed records successfully but produced a stale or incomplete snapshot, **When** an operator opens run detail, **Then** the page shows processing success separately from evidence usability and does not headline the run as unconditional success.
|
||||
2. **Given** a review-pack generation run completed technically but the resulting pack is only suitable for internal follow-up, **When** an operator opens run detail, **Then** the page explains the pack outcome separately from the run completion state and names the correct follow-up.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Read multi-cause degraded runs without flattening (Priority: P3)
|
||||
|
||||
An operator needs a degraded governance run with several contributing causes to stay understandable without collapsing into one vague abstract state.
|
||||
|
||||
**Why this priority**: Multi-cause degraded runs are where operator trust collapses fastest if the detail page is too generic.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a seeded multi-cause degraded governance run and verifying that the page names one dominant cause first while preserving additional cause context in a secondary breakdown.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant review generation run is limited by stale evidence and missing sections, **When** an operator opens run detail, **Then** the page shows one dominant cause with affected scale, preserves the second cause in secondary detail, and provides a next step that matches the dominant blocker.
|
||||
2. **Given** a governance run contains both retryable and structural issues, **When** an operator opens run detail, **Then** the default summary distinguishes the dominant follow-up path instead of flattening all causes into one generic inspection message.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A governance run can complete technically and still leave no decision-grade artifact. The page must explain that divergence directly instead of treating all-zero or fully processed counters as an all-clear.
|
||||
- A governance run can have no persisted related artifact because input was missing or output was intentionally suppressed. The summary must explain the absence without requiring a raw payload.
|
||||
- Multiple causes can have similar scale. The page must apply one stable dominant-cause rule so summary ordering does not become arbitrary between otherwise equivalent runs.
|
||||
- Raw diagnostics can be unavailable, collapsed, or intentionally deferred. The first-pass summary must remain understandable from the persisted run truth alone.
|
||||
- Scheduled or system-initiated governance runs can appear on the same page. The summary must stay humanized without implying that terminal user notifications or interactive start flows changed.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls, new mutation flows, or new scheduled or queued work. It changes the explanation hierarchy on the canonical Monitoring detail surface for already persisted governance runs.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded interpretation layer because direct mapping from existing outcome badges, reason labels, and raw context still forces operators to synthesize trust and next action themselves. A narrower per-family copy fix is insufficient because the same governance run families would drift apart. No new persistence, state family, or artifact truth source is added.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof remains in focused feature coverage for Monitoring run detail. No new heavy-governance or browser family is required. Fixture cost stays explicit and limited to seeded run scenarios where execution outcome, artifact usability, and dominant cause differ.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle rules remain unchanged. The feature does not change the three feedback surfaces, does not change `OperationRun.status` or `OperationRun.outcome` ownership, and does not introduce new `summary_counts` keys or non-numeric summary values. Scheduled or system-run behavior remains unchanged, including initiator-null notification rules. New regression guards focus on run-detail explanation order and summary-count meaning, not lifecycle transitions.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is the workspace-admin `/admin` Monitoring plane with tenant-entitlement enforcement for tenant-bound governance runs. Non-members or non-entitled viewers continue to receive 404. Members who can reach Monitoring but lack a currently required related action permission continue to receive 403 for that action. Existing server-side authorization remains authoritative for related artifact links and any linked mutation surfaces. Global search behavior is unchanged; `OperationRun` remains non-searchable and tenant-safe.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** No `/auth/*` behavior is introduced or broadened by this feature.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Any changed status emphasis on run detail continues to use centralized outcome, reason, and artifact-truth semantics. This feature changes ordering and explanation, not badge ownership or ad-hoc color rules.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament detail primitives, sections, infolist-style summary areas, and existing Monitoring detail components. Local replacement markup for status language is intentionally avoided. Semantic emphasis stays in shared truth primitives and summary ordering rather than page-local color or border rules.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the operation run. Primary summary language uses operator-facing terms such as completed with follow-up, blocked by prerequisite, partially trustworthy result, stale evidence basis, or internal-only pack outcome. Implementation-first terms such as raw reason-code slugs, payload keys, or support-tier labels remain secondary diagnostics only.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The affected surface remains a Tertiary Evidence / Diagnostics Surface. It does not become a new primary queue. Its human-in-the-loop purpose is to make one drilled-in governance case understandable without further reconstruction. Immediate visibility must include impact, dominant cause, trust direction, and next action. Raw diagnostics remain preserved but explicitly secondary.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The chosen action-surface class is record/detail because the operator is already inside one explicit run. The most likely next action is to open the related artifact or return to the source workflow with the correct next step. The one primary inspect model remains the existing operation-run detail page. There is no row click on the detail surface. Pure navigation stays in existing related links and does not compete with mutation. No destructive actions are added. Canonical routes remain `/admin/operations` and `/admin/operations/{run}`. Scope signals remain workspace context, tenant context when relevant, and related artifact family. The canonical noun remains `Operation run`.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** No header, row, bulk, or workbench action inventory changes are introduced. The feature must not use explanation hardening as a backdoor to add retry, cancel, force-fail, or other intervention controls.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content on `/admin/operations/{run}` must stay operator-first. Diagnostics are secondary and explicitly revealed below the primary summary. Status dimensions must stay distinct: execution outcome, artifact usability, dominant cause, and next-step category. Workspace and tenant context remain visible in the existing Monitoring detail shell. Any linked mutation continues to communicate its scope on the native surface where it lives.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from canonical run truth to UI is insufficient because current badges and raw payloads still require operator interpretation. This feature adds one bounded run-summary layer and does not introduce redundant truth across models, service results, presenters, wrappers, or persisted mirrors. Tests focus on business consequences: first-pass understanding, no false-green reading, and consistent next-step guidance.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The feature modifies a Filament-backed detail surface and therefore includes a UI Action Matrix. The Action Surface Contract remains satisfied: exactly one primary inspect model exists, redundant `View` actions remain absent, empty action groups remain absent, and no destructive placement changes occur. UI-FIL-001 is satisfied with the existing diagnostic-surface exception retained.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The affected screen remains a structured detail page. Humanized summary content must live in deliberate summary sections ahead of diagnostics, not as scattered helper text. No create or edit layout changes are introduced, and no UX-001 exemption is needed beyond the already accepted diagnostic detail nature of the page.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-220-001**: The system MUST derive a humanized governance-run summary from existing run outcome, reason translation, artifact-truth, and explanation inputs without creating a new persisted truth source.
|
||||
- **FR-220-002**: Canonical governance run detail MUST lead with exactly one dominant artifact-impact statement, one short supporting reason, one next-step category, and one affected-scale cue in the default-visible summary area.
|
||||
- **FR-220-003**: Governance run detail MUST keep processing success and throughput counts visibly separate from resulting artifact usability, trustworthiness, shareability, or decision-readiness.
|
||||
- **FR-220-004**: For multi-cause degraded governance runs, the detail page MUST identify one dominant cause first and preserve additional causes in a secondary breakdown instead of flattening them into one generic state.
|
||||
- **FR-220-005**: Next-step guidance on governance run detail MUST distinguish at least retry later, resume capture or generation, refresh prerequisite data, review scope or ambiguous matches, manually validate, and no further action when the persisted truth supports those distinctions.
|
||||
- **FR-220-006**: Raw JSON, raw reason-code inventories, provider payloads, and low-level counters MUST remain available on governance run detail but MUST not be the first explanatory block.
|
||||
- **FR-220-007**: The same cause class across covered governance run families MUST render with the same primary reading direction and next-step category on canonical run detail.
|
||||
- **FR-220-008**: The first implementation slice MUST cover governance runs for baseline capture, baseline compare, evidence snapshot generation (`tenant.evidence.snapshot.generate`), tenant review composition (`tenant.review.compose`), and review-pack generation (`tenant.review_pack.generate`).
|
||||
- **FR-220-009**: A governance run that completed technically but produced a degraded, blocked, stale, internal-only, or otherwise non-decision-grade artifact MUST explain that divergence explicitly and MUST NOT headline as unconditional success.
|
||||
- **FR-220-010**: All-zero or zero-output governance runs MUST explain why no decision-grade result exists and MUST NOT read as neutral or implicit all-clear.
|
||||
- **FR-220-011**: Humanized summaries, affected-scale cues, and related artifact links on canonical Monitoring run detail MUST remain tenant-safe and must not leak inaccessible tenant context or artifact hints.
|
||||
- **FR-220-012**: This feature MUST NOT introduce new `OperationRun` statuses, outcomes, reason-code families, `summary_counts` keys, notification surfaces, or run-detail intervention controls.
|
||||
- **FR-220-013**: Existing action inventory on operation-run detail MUST remain unchanged; humanized summaries must not add retry, cancel, force-fail, or other mutation controls.
|
||||
- **FR-220-014**: Primary summary vocabulary on governance run detail MUST use the shared operator language established by Specs 156, 157, 158, 161, and 214 rather than implementation-first labels.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Canonical Monitoring operation run detail for governance operations | `apps/platform/app/Filament/Pages/Monitoring/Operations.php`; `apps/platform/app/Filament/Resources/OperationRunResource.php` | none added | Existing explicit navigation from the operations list or related links remains the only inspect model | none added | none | n/a | Existing related-artifact navigation remains; no new action labels introduced by this feature | n/a | no new audit behavior | Action Surface Contract remains satisfied. No redundant `View` action, no empty action groups, no destructive change. Existing diagnostic exception remains, but summary-first hierarchy becomes mandatory. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Humanized Governance Run Summary**: A derived first-pass summary for one governance operation run containing the dominant artifact impact, short reason, affected scale, and next-step direction.
|
||||
- **Dominant Cause Breakdown**: A derived secondary explanation that preserves additional causes when a governance run is degraded for more than one reason.
|
||||
- **Artifact Impact Statement**: The operator-facing truth about whether the resulting artifact is trustworthy, limited, blocked, internal-only, stale, or otherwise unsuitable for immediate reliance, separate from execution success.
|
||||
|
||||
## Assumptions & Dependencies
|
||||
|
||||
- Specs 156, 157, 158, 161, and 214 remain the authoritative foundations for operator vocabulary, reason translation, artifact-truth semantics, explanation patterns, and governance-surface compression.
|
||||
- The canonical Monitoring run viewer from Spec 144 remains the existing detail surface and data-access contract for this slice.
|
||||
- Covered governance run families already persist enough reason and outcome data to drive a first-pass summary without adding new persistence.
|
||||
- This spec intentionally stays on run detail and does not pull surrounding artifact list or detail surfaces back into scope.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesign the operations list, Monitoring landing page, or dashboard attention surfaces.
|
||||
- Add retry, cancel, force-fail, or reconcile-now controls to run detail.
|
||||
- Remove raw JSON or low-level diagnostics from the run-detail page.
|
||||
- Create a new lifecycle or status model for `OperationRun`.
|
||||
- Expand the slice to every non-governance run family.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-220-001**: In seeded acceptance review, an operator can determine within 15 seconds from the canonical governance run-detail page what happened, whether the resulting artifact is trustworthy enough to act on, and what the next step is without opening raw diagnostics.
|
||||
- **SC-220-002**: In automated coverage, 100% of covered scenarios where execution success diverges from artifact trust show those truths as separate visible statements with no contradictory headline.
|
||||
- **SC-220-003**: In automated coverage, 100% of covered multi-cause degraded governance runs show one dominant cause first and preserve at least one additional cause in secondary detail.
|
||||
- **SC-220-004**: In acceptance review and regression tests, raw JSON and low-level diagnostics are never the first explanatory block on the run-detail page for any covered governance run family.
|
||||
146
specs/220-governance-run-summaries/tasks.md
Normal file
146
specs/220-governance-run-summaries/tasks.md
Normal file
@ -0,0 +1,146 @@
|
||||
# Tasks: Humanized Diagnostic Summaries for Governance Operations
|
||||
|
||||
**Input**: Design documents from `/specs/220-governance-run-summaries/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/governance-run-summaries.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior on a Filament-backed Monitoring detail surface, so Pest feature and unit coverage must ship with the implementation.
|
||||
|
||||
**Test Governance Checklist**
|
||||
|
||||
- Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for this surface change.
|
||||
- New tests stay in focused Monitoring and unit suites; no heavy-governance or browser family is introduced.
|
||||
- Shared helpers and fixtures remain opt-in, especially `BuildsGovernanceArtifactTruthFixtures`.
|
||||
- Validation commands stay limited to the focused run-detail suites listed in `specs/220-governance-run-summaries/quickstart.md`.
|
||||
- The declared surface profile remains `monitoring-state-page`.
|
||||
- Any budget or escalation note stays inside this feature instead of becoming a follow-up spec.
|
||||
|
||||
## Phase 1: Setup (Shared Test Scaffolding)
|
||||
|
||||
**Purpose**: Create the focused test seams and fixture hooks the implementation will use.
|
||||
|
||||
- [X] T001 [P] Create the focused canonical run-detail feature suite and local scenario helpers for zero-output and multi-cause runs in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
|
||||
- [X] T002 [P] Create the focused summary-derivation unit suite in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
|
||||
- [X] T003 [P] Extend only generic opt-in shared governance fixture builders for blocked, stale, and internal-only artifact cases in `apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared derived-summary seam that all user stories build on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T004 Create the derived summary value object in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php`
|
||||
- [X] T005 Create the shared summary builder with canonical `OperationRun`, artifact-truth, reason, and explanation inputs in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T006 Wire memoized governance summary access into `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- [X] T007 [P] Add guard coverage that summary derivation preserves canonical `summary_counts` meaning and does not invent new count keys in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
|
||||
- [X] T008 [P] Extend canonical operator-language assertions and explicit next-step category matrix coverage for `retry later`, `resume capture or generation`, `refresh prerequisite data`, `review scope or ambiguous matches`, `manually validate`, and `no further action` in `apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` and `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
|
||||
|
||||
**Checkpoint**: The shared summary seam exists, is memoized through the current Ops UX presenter, and is guarded against count-contract drift.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Understand the dominant problem fast (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make the canonical governance run-detail page explain the dominant problem, affected scale, and next step before any raw diagnostics.
|
||||
|
||||
**Independent Test**: Open seeded baseline-capture and baseline-compare runs on `/admin/operations/{run}` and confirm the default-visible summary answers what happened and what to do next without opening diagnostic sections.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T009 [P] [US1] Add feature scenarios for baseline-capture and baseline-compare summary-first hierarchy, no new header actions, and zero-output messaging in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
|
||||
- [X] T010 [P] [US1] Add unit cases for dominant headline, supporting reason, affected-scale cue, and next-step selection for baseline-capture and baseline-compare runs in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Implement `baseline.capture` and `baseline.compare` summary mappings in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T012 [US1] Expose baseline summary facts through the memoized presenter API in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- [X] T013 [US1] Render the default-visible summary block before technical diagnostics in `apps/platform/app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T014 [US1] Keep canonical context, lifecycle, and restore banners specialized without duplicating the dominant explanation in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- [X] T015 [US1] Preserve summary-first page-shell order for canonical run detail in `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||
- [X] T016 [US1] Update summary fallback expectations for the new first-read hierarchy in `apps/platform/tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php`
|
||||
- [X] T017 [US1] Update run-detail hierarchy assertions so diagnostics stay secondary in `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
||||
|
||||
**Checkpoint**: Baseline capture and baseline compare runs are readable from the summary block alone, with diagnostics preserved but no longer leading the page.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Separate processing success from artifact trust (Priority: P2)
|
||||
|
||||
**Goal**: Keep execution completion visible while clearly separating whether the resulting artifact is trustworthy, limited, stale, or internal-only.
|
||||
|
||||
**Independent Test**: Open seeded evidence-snapshot and review-pack runs where processing completed but the artifact is not decision-grade, and confirm the page shows those truths as separate visible statements.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T018 [P] [US2] Add feature scenarios for evidence-snapshot and review-pack runs that separate processing completion from artifact trust in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
|
||||
- [X] T019 [P] [US2] Add regression assertions for execution-outcome versus artifact-impact separation in `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
|
||||
- [X] T020 [P] [US2] Add positive and negative authorization coverage for tenant-safe summary rendering and related links in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T021 [US2] Implement `tenant.evidence.snapshot.generate` and `tenant.review_pack.generate` summary mappings with distinct execution and artifact-impact facts in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T022 [US2] Render separated execution outcome and artifact-impact facts in `apps/platform/app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T023 [US2] Keep related artifact navigation and tenant-context continuity aligned with summary copy in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- [X] T024 [US2] Extend canonical route isolation assertions for deny-as-not-found and in-scope `403` behavior in `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
|
||||
**Checkpoint**: A technically completed run can no longer read like unconditional success when the artifact itself is stale, limited, or internal-only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Read multi-cause degraded runs without flattening (Priority: P3)
|
||||
|
||||
**Goal**: Keep degraded governance runs understandable by showing one dominant cause first while preserving secondary causes and affected-scale context.
|
||||
|
||||
**Independent Test**: Open a seeded multi-cause tenant-review run on `/admin/operations/{run}` and confirm the page shows one dominant cause first, preserves secondary causes, and keeps the same ordering across reloads.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Add feature scenarios for tenant-review multi-cause degraded runs, stable dominant-cause ordering, and cross-family parity for the same cause class across at least two covered governance families in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
|
||||
- [X] T026 [P] [US3] Add unit cases for dominant-cause ranking, secondary causes, and affected-scale confidence in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T027 [US3] Implement `tenant.review.compose` multi-cause summary mapping and shared ranking rules across covered governance families in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T028 [US3] Render secondary-cause breakdown and affected-scale detail without flattening the dominant explanation in `apps/platform/app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T029 [US3] Suppress inaccessible tenant and artifact hints in summary text and related-navigation branches in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T030 [US3] Keep canonical run-detail banners and page-shell copy free of duplicated multi-cause messaging in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- [X] T031 [US3] Extend authorization surface assertions so inaccessible related context never leaks through summary or navigation output in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
|
||||
|
||||
**Checkpoint**: Multi-cause degraded runs stay human-readable, deterministically ordered, and tenant-safe.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final guardrail review, formatting, focused validation, and manual smoke.
|
||||
|
||||
- [X] T032 [P] Review monitoring-state-page guardrail coverage, lane assignment, and fixture-cost notes against `specs/220-governance-run-summaries/plan.md` and `specs/220-governance-run-summaries/quickstart.md`
|
||||
- [X] T033 [P] Format changed PHP and Blade files including `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||
- [X] T034 Run the canonical proving commands for `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`, and `apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php`
|
||||
- [X] T035 [P] Execute the manual smoke checks for summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, and tenant-safe related links in `specs/220-governance-run-summaries/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Setup tasks `T001-T003` can begin immediately.
|
||||
- Foundational tasks `T004-T008` depend on setup and block all story work.
|
||||
- User Story 1 depends on Phase 2 and is the MVP slice.
|
||||
- User Story 2 depends on Phase 2 and the shared summary rendering established in User Story 1 because it extends the same builder and canonical detail surface.
|
||||
- User Story 3 depends on Phase 2 and should follow User Story 1 because it extends the same ranking and rendering seams; it can overlap with late User Story 2 test work once the shared builder contract is stable.
|
||||
- Polish tasks depend on all user stories being complete.
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- **US1**: Run `T009` and `T010` together; after `T011-T012`, split `T013`, `T014`, and `T015` across different files.
|
||||
- **US2**: Run `T018`, `T019`, and `T020` together; after `T021`, split `T022`, `T023`, and `T024` across resource, page, and authorization files.
|
||||
- **US3**: Run `T025` and `T026` together; after `T027`, split `T028`, `T029`, and `T030` while keeping `T031` as the final authorization proof.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
- Finish Setup and Foundational phases first so the derived summary seam and opt-in fixtures are stable.
|
||||
- Deliver User Story 1 as the MVP because it provides the first operator-visible improvement on canonical run detail.
|
||||
- Extend the same seam through User Story 2 to separate execution success from artifact trust across additional governance families.
|
||||
- Finish with User Story 3 to lock deterministic multi-cause ranking and no-leak summary behavior.
|
||||
- Close with formatting, focused proving commands, and the manual smoke pass documented in `quickstart.md`.
|
||||
Loading…
Reference in New Issue
Block a user