merge: agent session work (LEAN-001 + finding ownership + backup_set unification)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
This commit is contained in:
commit
0c78e3e1b0
19
.github/agents/copilot-instructions.md
vendored
19
.github/agents/copilot-instructions.md
vendored
@ -220,6 +220,8 @@ ## 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 (feat/005-bulk-operations)
|
||||
|
||||
@ -254,11 +256,20 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 remains the next open 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,8 @@ ## 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`)
|
||||
- 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,91 +144,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
|
||||
@ -254,8 +171,8 @@ ### Humanized Diagnostic Summaries for Governance Operations
|
||||
- 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.
|
||||
- **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), Spec 214 (Governance Operator Outcome Compression)
|
||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Spec 214 (Governance Operator Outcome Compression). Spec 214 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**
|
||||
@ -267,9 +184,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.
|
||||
> 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** — 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.
|
||||
> 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 +244,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 +297,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 +380,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.
|
||||
Loading…
Reference in New Issue
Block a user