Compare commits

...

3 Commits

Author SHA1 Message Date
bd06b479e1 feat: add governance run summaries (#257)
Some checks failed
Main Confidence / confidence (push) Failing after 43s
## Summary
- add the Spec 220 governance run diagnostic summary seam and wire it through the canonical operation run detail presenter
- render summary-first decision guidance for covered governance run families while keeping technical diagnostics secondary
- add focused Pest coverage, spec artifacts, and complete the integrated-browser smoke validation for canonical run detail

## Testing
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php
- integrated browser smoke pass on localhost:8081 covering summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, workspace-wide visibility, and deny-as-not-found tenant safety

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #257
2026-04-20 20:46:09 +00:00
c86b399b43 feat(219): Finding ownership semantics + LEAN-001 constitution + backup_set unification (#256)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
## Summary

This PR delivers three related improvements:

### 1. Finding Ownership Semantics (Spec 219)
- Add responsibility/accountability labels to findings and finding exceptions
- `owner_user_id` = accountable party (governance owner)
- `assignee_user_id` = responsible party (technical implementer)
- Expose Assign/Reassign actions in FindingResource with audit logging
- Add ownership columns and filters to finding list
- Propagate owner from finding to exception on creation
- Tests: ownership semantics, assignment audit, workflow actions

### 2. Constitution v2.7.0 — LEAN-001 Pre-Production Lean Doctrine
- New principle forbidding legacy aliases, migration shims, dual-write logic, and compatibility fixtures in a pre-production codebase
- AI-agent 4-question verification gate before adding any compatibility path
- Review rule: compatibility shims without answering the gate questions = merge blocker
- Exit condition: LEAN-001 expires at first production deployment
- Spec template: added default "Compatibility posture" block
- Agent instructions: added "Pre-production compatibility check" section

### 3. Backup Set Operation Type Unification
- Unified `backup_set.add_policies` and `backup_set.remove_policies` into single canonical `backup_set.update`
- Removed all legacy aliases, constants, and test fixtures
- Added lifecycle coverage for `backup_set.update` in config
- Updated all 14+ test files referencing legacy types

### Spec Artifacts
- `specs/219-finding-ownership-semantics/` — full spec, plan, tasks, research, data model, contracts, checklist

### Tests
- All affected tests pass (OperationCatalog, backup set, finding workflow, ownership semantics)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #256
2026-04-20 17:54:33 +00:00
a089350f98 feat: unify provider-backed action dispatch gating (#255)
Some checks failed
Main Confidence / confidence (push) Failing after 49s
## Summary
- unify provider-backed action starts behind the shared provider dispatch gate and shared start-result presenter
- align tenant, onboarding, provider-connection, restore, directory, and monitoring surfaces with the same blocked, deduped, scope-busy, and accepted semantics
- include the spec kit artifacts for spec 216 and the regression fixes that brought the full suite back to green

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RestoreRunIdempotencyTest.php tests/Feature/ExecuteRestoreRunJobTest.php tests/Feature/Restore/RestoreRunProviderStartTest.php tests/Feature/Hardening/ExecuteRestoreRunJobGateTest.php tests/Feature/Hardening/BlockedWriteAuditLogTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`

## Notes
- branch: `216-provider-dispatch-gate`
- commit: `34230be7`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #255
2026-04-20 06:52:38 +00:00
140 changed files with 9645 additions and 1095 deletions

View File

@ -216,8 +216,14 @@ ## Active Technologies
- PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression)
- 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 (215-website-core-pages)
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
- 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-provider-dispatch-gate)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders (220-governance-run-summaries)
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -252,10 +258,20 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 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
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
<!-- 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 -->

View File

@ -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

View File

@ -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.

View File

@ -13,7 +13,9 @@ trait ResolvesPanelTenantContext
{
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$request = request();
if (static::currentPanelId($request) === 'admin') {
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
return $tenant instanceof Tenant ? $tenant : null;
@ -49,4 +51,41 @@ protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
{
return static::resolveTenantContextForCurrentPanelOrFail();
}
private static function currentPanelId(mixed $request): ?string
{
$panelId = Filament::getCurrentPanel()?->getId();
if (is_string($panelId) && $panelId !== '') {
return $panelId;
}
$routeName = is_object($request) && method_exists($request, 'route')
? $request->route()?->getName()
: null;
if (is_string($routeName) && $routeName !== '') {
if (str_contains($routeName, '.tenant.')) {
return 'tenant';
}
if (str_contains($routeName, '.admin.')) {
return 'admin';
}
}
$path = is_object($request) && method_exists($request, 'path')
? '/'.ltrim((string) $request->path(), '/')
: null;
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
return 'tenant';
}
if (is_string($path) && str_starts_with($path, '/admin/')) {
return 'admin';
}
return null;
}
}

View File

@ -521,7 +521,7 @@ public function basisRunSummary(): array
'badgeColor' => null,
'runUrl' => null,
'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
];
}
@ -537,7 +537,7 @@ public function basisRunSummary(): array
'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
];
}

View File

@ -246,21 +246,10 @@ public function blockedExecutionBanner(): ?array
return null;
}
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $operatorExplanation instanceof OperatorExplanationPattern
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
]))
: ($reasonEnvelope?->toBodyLines(false) ?? [
$this->surfaceFailureDetail() ?? 'The queued operation was refused before side effects could begin.',
]);
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', array_values(array_unique($lines))),
'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
];
}

View File

@ -122,17 +122,17 @@ public function table(Table $table): Table
TextColumn::make('outcome')
->label('Outcome')
->badge()
->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryLabel)
->color(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryBadge->color)
->icon(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->icon)
->iconColor(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->iconColor)
->description(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryReason)
->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeLabel']))
->color(\Closure::fromCallable([$this, 'reviewOutcomeBadgeColor']))
->icon(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIcon']))
->iconColor(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIconColor']))
->description(\Closure::fromCallable([$this, 'reviewOutcomeDescription']))
->wrap(),
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->nextActionText)
->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeNextStep']))
->wrap(),
])
->filters([
@ -330,13 +330,46 @@ private function reviewTruth(TenantReview $record, bool $fresh = false): Artifac
: $presenter->forTenantReview($record);
}
private function reviewOutcomeLabel(TenantReview $record): string
{
return $this->reviewOutcome($record)->primaryLabel;
}
private function reviewOutcomeBadgeColor(TenantReview $record): string
{
return $this->reviewOutcome($record)->primaryBadge->color;
}
private function reviewOutcomeBadgeIcon(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryBadge->icon;
}
private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryReason;
}
private function reviewOutcomeNextStep(TenantReview $record): string
{
return $this->reviewOutcome($record)->nextActionText;
}
private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? $this->reviewTruth($record, true)
: $this->reviewTruth($record);
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh)
?? $presenter->compressedOutcomeFromEnvelope(
$this->reviewTruth($record, $fresh),
$truth,
SurfaceCompressionContext::reviewRegister(),
);
}

View File

@ -46,6 +46,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
@ -2873,65 +2874,22 @@ public function startVerification(): void
);
}
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()),
);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
$notification->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
$notification->send();
return;
}
@ -2939,24 +2897,12 @@ public function startVerification(): void
OpsUxBrowserEvents::dispatchRunEnqueued($this);
if ($result->status === 'deduped') {
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
$notification->send();
return;
}
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
$notification->send();
}
public function refreshVerificationStatus(): void
@ -3056,85 +3002,73 @@ public function startBootstrap(array $operationTypes): void
actor: $user,
expectedVersion: $this->expectedDraftVersion(),
mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void {
$lockedConnection = ProviderConnection::query()
->whereKey($connection->getKey())
->lockForUpdate()
->firstOrFail();
$nextOperationType = $this->nextBootstrapOperationType($draft, $types, (int) $connection->getKey());
$activeRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id')
->first();
if ($activeRun instanceof OperationRun) {
if ($nextOperationType === null) {
$result = [
'status' => 'scope_busy',
'run' => $activeRun,
'status' => 'already_completed',
'operation_type' => null,
'remaining_types' => [],
];
return;
}
$runsService = app(OperationRunService::class);
$bootstrapRuns = [];
$bootstrapCreated = [];
$capability = $this->resolveBootstrapCapability($nextOperationType);
foreach ($types as $operationType) {
$definition = $registry->get($operationType);
if ($capability === null) {
throw new RuntimeException("Unsupported bootstrap operation type: {$nextOperationType}");
}
$context = [
$startResult = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: $connection,
operationType: $nextOperationType,
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection, $nextOperationType): void {
$this->dispatchBootstrapJob(
operationType: $nextOperationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
run: $run,
);
},
initiator: $user,
extraContext: [
'wizard' => [
'flow' => 'managed_tenant_onboarding',
'step' => 'bootstrap',
],
'provider' => $lockedConnection->provider,
'module' => $definition['module'],
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
],
];
$run = $runsService->ensureRunWithIdentity(
tenant: $tenant,
type: $operationType,
identityInputs: [
'provider_connection_id' => (int) $lockedConnection->getKey(),
],
context: $context,
initiator: $user,
);
if ($run->wasRecentlyCreated) {
$this->dispatchBootstrapJob(
operationType: $operationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $lockedConnection->getKey(),
run: $run,
);
}
$bootstrapRuns[$operationType] = (int) $run->getKey();
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
}
'required_capability' => $capability,
],
);
$state = $draft->state ?? [];
$existing = $state['bootstrap_operation_runs'] ?? [];
$existing = is_array($existing) ? $existing : [];
$state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns);
if ($startResult->status !== 'scope_busy') {
$existing[$nextOperationType] = (int) $startResult->run->getKey();
}
$state['bootstrap_operation_runs'] = $existing;
$state['bootstrap_operation_types'] = $types;
$draft->state = $state;
$draft->current_step = 'bootstrap';
$remainingTypes = array_values(array_filter(
$types,
fn (string $candidate): bool => $candidate !== $nextOperationType
&& ! $this->bootstrapOperationSucceeded($draft, $candidate, (int) $connection->getKey()),
));
$result = [
'status' => 'started',
'runs' => $bootstrapRuns,
'created' => $bootstrapCreated,
'status' => $startResult->status,
'start_result' => $startResult,
'operation_type' => $nextOperationType,
'run' => $startResult->run,
'remaining_types' => $remainingTypes,
];
},
));
@ -3152,26 +3086,36 @@ public function startBootstrap(array $operationTypes): void
throw new RuntimeException('Bootstrap start did not return a run result.');
}
if ($result['status'] === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
if ($result['status'] === 'already_completed') {
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())),
])
->title('Bootstrap already completed')
->body('All selected bootstrap actions have already finished successfully for this provider connection.')
->info()
->send();
return;
}
$bootstrapRuns = $result['runs'];
$operationType = (string) ($result['operation_type'] ?? '');
$startResult = $result['start_result'] ?? null;
$run = $result['run'] ?? null;
if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') {
throw new RuntimeException('Bootstrap start did not return a canonical run result.');
}
$remainingTypes = is_array($result['remaining_types'] ?? null)
? array_values(array_filter($result['remaining_types'], static fn (mixed $value): bool => is_string($value) && $value !== ''))
: [];
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$auditStatus = match ($result['status']) {
'started' => 'success',
'deduped' => 'deduped',
'scope_busy' => 'blocked',
default => 'success',
};
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
@ -3181,36 +3125,40 @@ public function startBootstrap(array $operationTypes): void
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_types' => $types,
'operation_run_ids' => $bootstrapRuns,
'started_operation_type' => $operationType,
'operation_run_id' => (int) $run->getKey(),
'result' => (string) $result['status'],
],
],
actor: $user,
status: 'success',
status: $auditStatus,
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(),
);
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $startResult,
blockedTitle: 'Bootstrap action blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()),
scopeBusyTitle: 'Bootstrap action busy',
scopeBusyBody: $remainingTypes !== []
? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.'
: 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.',
);
foreach ($types as $operationType) {
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
}
$toast = $wasCreated
? OperationUxPresenter::queuedToast($operationType)
: OperationUxPresenter::alreadyQueuedToast($operationType);
$notification->send();
if ($runUrl !== null) {
$toast->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
]);
}
$toast->send();
if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) {
Notification::make()
->title('Continue bootstrap after this run finishes')
->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes)))
->info()
->send();
}
}
@ -3227,17 +3175,65 @@ private function dispatchBootstrapJob(
userId: $userId,
providerConnectionId: $providerConnectionId,
operationRun: $run,
),
)->afterCommit(),
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
tenantId: $tenantId,
userId: $userId,
providerConnectionId: $providerConnectionId,
operationRun: $run,
),
)->afterCommit(),
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
};
}
/**
* @param array<int, string> $types
*/
private function nextBootstrapOperationType(TenantOnboardingSession $draft, array $types, int $providerConnectionId): ?string
{
foreach ($types as $type) {
if (! $this->bootstrapOperationSucceeded($draft, $type, $providerConnectionId)) {
return $type;
}
}
return null;
}
private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, string $type, int $providerConnectionId): bool
{
$state = is_array($draft->state) ? $draft->state : [];
$runMap = $state['bootstrap_operation_runs'] ?? [];
if (! is_array($runMap)) {
return false;
}
$runId = $runMap[$type] ?? null;
if (! is_numeric($runId)) {
return false;
}
$run = OperationRun::query()->whereKey((int) $runId)->first();
if (! $run instanceof OperationRun) {
return false;
}
$context = is_array($run->context ?? null) ? $run->context : [];
$runProviderConnectionId = is_numeric($context['provider_connection_id'] ?? null)
? (int) $context['provider_connection_id']
: null;
if ($runProviderConnectionId !== $providerConnectionId) {
return false;
}
return $run->status === OperationRunStatus::Completed->value
&& $run->outcome === OperationRunOutcome::Succeeded->value;
}
private function resolveBootstrapCapability(string $operationType): ?string
{
return match ($operationType) {

View File

@ -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,

View File

@ -182,7 +182,11 @@ public static function table(Table $table): Table
->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor)
->description(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryReason)
->description(static fn (BaselineSnapshot $record): ?string => self::truthHeadline($record))
->wrap(),
TextColumn::make('coverage_summary')
->label('Coverage')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(),
TextColumn::make('next_step')
->label('Next step')
@ -377,6 +381,12 @@ private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh =
: $presenter->forBaselineSnapshot($snapshot);
}
private static function truthHeadline(BaselineSnapshot $record): ?string
{
return self::truthEnvelope($record)->operatorExplanation?->headline
?? self::compressedOutcome($record)->primaryReason;
}
private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);

View File

@ -3,16 +3,14 @@
namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService;
use App\Services\Directory\EntraGroupSyncService;
use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -55,7 +53,7 @@ protected function getHeaderActions(): array
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('primary')
->action(function (): void {
->action(function (EntraGroupSyncService $syncService): void {
$user = auth()->user();
$tenant = EntraGroupResource::panelTenantContext();
@ -63,52 +61,18 @@ protected function getHeaderActions(): array
return;
}
$selectionKey = EntraGroupSelection::allGroupsV1();
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
$result = $syncService->startManualSync($tenant, $user);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Directory groups sync blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
// ----------------------------------------------
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
operationRun: $opRun
));
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$notification->send();
})
)
->requireCapability(Capabilities::TENANT_SYNC)

View File

@ -691,9 +691,12 @@ private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = fa
private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array
{
$presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? static::truthEnvelope($record, true)
: static::truthEnvelope($record);
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh)
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
?? $truth->toArray(static::compressedOutcome($record, $fresh));
}
private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome

View File

@ -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)

View File

@ -316,7 +316,13 @@ public static function getEloquentQuery(): Builder
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()->with('lastSeenRun'),
$tenant,
);
}
public static function getPages(): array

View File

@ -2,14 +2,30 @@
namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewInventoryItem extends ViewRecord
{
use ResolvesPanelTenantContext;
protected static string $resource = InventoryItemResource::class;
public function mount(int|string $record): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof Tenant) {
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
}
parent::mount($record);
}
protected function resolveRecord(int|string $key): Model
{
return InventoryItemResource::resolveScopedRecordOrFail($key);

View File

@ -280,16 +280,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
: null;
$artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation;
$diagnosticSummary = OperationUxPresenter::governanceDiagnosticSummary($record);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$decisionNextStep = $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? [
'text' => $diagnosticSummary->nextActionText,
'source' => $diagnosticSummary->nextActionCategory,
'secondaryGuidance' => $primaryNextStep['secondaryGuidance'],
]
: $primaryNextStep;
$restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups(
record: $record,
factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle,
diagnosticSummary: $diagnosticSummary,
operatorExplanation: $operatorExplanation,
reasonEnvelope: $reasonEnvelope,
primaryNextStep: $primaryNextStep,
primaryNextStep: $decisionNextStep,
);
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
@ -307,49 +316,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
))
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$outcomeSpec->label,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'],
$primaryNextStep['source'],
$primaryNextStep['secondaryGuidance'],
facts: static::decisionFacts(
factory: $factory,
record: $record,
statusSpec: $statusSpec,
outcomeSpec: $outcomeSpec,
artifactTruth: $artifactTruth,
operatorExplanation: $operatorExplanation,
restoreContinuation: $restoreContinuation,
diagnosticSummary: $diagnosticSummary,
),
description: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.',
primaryNextStep: $factory->primaryNextStep(
$decisionNextStep['text'],
$decisionNextStep['source'],
$decisionNextStep['secondaryGuidance'],
'Primary next step',
),
description: $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? 'Start here to see what happened, how reliable the resulting artifact is, what was affected, and the one next step.'
: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.',
compactCounts: $summaryLine !== null
? $factory->countPresentation(summaryLine: $summaryLine)
: null,
@ -550,6 +535,7 @@ private static function supportingGroups(
OperationRun $record,
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $primaryNextStep,
@ -559,6 +545,21 @@ private static function supportingGroups(
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
$guidanceItems = array_values(array_filter([
...array_map(
static fn (array $fact): array => $factory->keyFact(
(string) ($fact['label'] ?? 'Summary detail'),
(string) ($fact['value'] ?? '—'),
is_string($fact['hint'] ?? null) ? $fact['hint'] : null,
tone: match ($fact['emphasis'] ?? null) {
'blocked' => 'danger',
'caution' => 'warning',
default => null,
},
),
$diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? array_values(array_filter($diagnosticSummary->secondaryFacts, 'is_array'))
: [],
),
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
@ -811,6 +812,8 @@ private static function guidanceLabel(string $source): string
private static function artifactTruthFact(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ArtifactTruthEnvelope $artifactTruth,
?string $hintOverride = null,
bool $preferOverride = false,
): ?array {
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
return null;
@ -823,19 +826,138 @@ private static function artifactTruthFact(
$badge = $outcome->primaryBadge;
return $factory->keyFact(
'Outcome',
'Artifact impact',
$outcome->primaryLabel,
$outcome->primaryReason,
$preferOverride ? $hintOverride : ($hintOverride ?? $outcome->primaryReason),
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
);
}
/**
* @return list<array<string, mixed>>
*/
private static function decisionFacts(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
OperationRun $record,
\App\Support\Badges\BadgeSpec $statusSpec,
\App\Support\Badges\BadgeSpec $outcomeSpec,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
mixed $restoreContinuation,
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
): array {
if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) {
return array_values(array_filter([
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$outcomeSpec->label,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
]));
}
$facts = [
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$diagnosticSummary->executionOutcomeLabel,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact(
$factory,
$artifactTruth,
static::detailHintUnlessDuplicate(
$diagnosticSummary->headline,
$artifactTruth?->primaryExplanation,
$diagnosticSummary->primaryReason,
),
true,
),
$factory->keyFact(
'Dominant cause',
$diagnosticSummary->dominantCause['label'],
$diagnosticSummary->primaryReason,
tone: in_array($diagnosticSummary->nextActionCategory, ['refresh_prerequisite_data', 'review_scope_or_ambiguous_matches'], true)
? 'warning'
: (in_array($diagnosticSummary->nextActionCategory, ['retry_later', 'no_further_action'], true) ? null : 'danger'),
),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$diagnosticSummary->primaryReason,
),
tone: match ($operatorExplanation->trustworthinessLevel->value) {
'unusable' => 'danger',
'diagnostic_only', 'limited_confidence' => 'warning',
default => 'success',
},
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
];
if (is_array($diagnosticSummary->affectedScaleCue)) {
$source = str_replace('_', ' ', (string) ($diagnosticSummary->affectedScaleCue['source'] ?? 'recorded detail'));
$facts[] = $factory->keyFact(
(string) ($diagnosticSummary->affectedScaleCue['label'] ?? 'Affected scale'),
(string) ($diagnosticSummary->affectedScaleCue['value'] ?? 'Recorded detail is available.'),
'Backed by '.$source.'.',
);
}
return array_values(array_filter($facts));
}
private static function decisionAttentionNote(OperationRun $record): ?string
{
return OperationUxPresenter::decisionAttentionNote($record);
}
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
private static function detailHintUnlessDuplicate(?string $hint, ?string ...$duplicates): ?string
{
$normalizedHint = static::normalizeDetailText($hint);
@ -843,8 +965,10 @@ private static function detailHintUnlessDuplicate(?string $hint, ?string $duplic
return null;
}
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
return null;
foreach ($duplicates as $duplicate) {
if ($normalizedHint === static::normalizeDetailText($duplicate)) {
return null;
}
}
return trim($hint ?? '');

View File

@ -21,6 +21,7 @@
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
@ -1357,20 +1358,23 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
initiator: $user,
);
$runUrl = OperationRunLinks::view($result->run, $tenant);
$extraActions = $result->status === 'started'
? []
: [
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
];
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Connection check blocked',
runUrl: $runUrl,
extraActions: $extraActions,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
$notification->send();
return;
}
@ -1378,50 +1382,20 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
$notification->send();
return;
}
if ($result->status === 'blocked') {
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Connection check blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
$notification->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
$notification->send();
}
/**
@ -1452,17 +1426,14 @@ private static function handleProviderOperationAction(
initiator: $user,
);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: $blockedTitle,
runUrl: OperationRunLinks::view($result->run, $tenant),
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
$notification->send();
return;
}
@ -1470,44 +1441,20 @@ private static function handleProviderOperationAction(
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
$notification->send();
return;
}
if ($result->status === 'blocked') {
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title($blockedTitle)
->body(implode("\n", $bodyLines))
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
$notification->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
$notification->send();
}
public static function getEloquentQuery(): Builder

View File

@ -14,6 +14,7 @@
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
@ -26,6 +27,8 @@
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain;
@ -35,6 +38,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus;
@ -1917,6 +1921,53 @@ public static function createRestoreRun(array $data): RestoreRun
->executionSafetySnapshot($tenant, $user, $data)
->toArray();
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if (! in_array($result->status, ['started', 'deduped'], true)) {
throw new \Filament\Support\Exceptions\Halt;
}
if (! $restoreRun instanceof RestoreRun) {
throw new \RuntimeException('Restore execution was accepted without creating a restore run.');
}
return $restoreRun;
}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, mixed> $preview
* @param array<string, mixed> $metadata
* @param array<string, mixed> $groupMapping
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
*/
private static function startQueuedRestoreExecution(
Tenant $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
array $preview,
array $metadata,
array $groupMapping,
?string $actorEmail,
?string $actorName,
): array {
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
@ -1924,34 +1975,27 @@ public static function createRestoreRun(array $data): RestoreRun
groupMapping: $groupMapping,
);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$initiator = auth()->user();
$initiator = $initiator instanceof User ? $initiator : null;
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
$queuedRestoreRun = null;
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return $existing;
}
try {
$restoreRun = RestoreRun::create([
$dispatcher = function (OperationRun $run) use (
$tenant,
$backupSet,
$selectedItemIds,
$preview,
$metadata,
$groupMapping,
$actorEmail,
$actorName,
$idempotencyKey,
&$queuedRestoreRun,
): void {
$queuedRestoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'operation_run_id' => $run->getKey(),
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
@ -1961,83 +2005,114 @@ public static function createRestoreRun(array $data): RestoreRun
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
$context = is_array($run->context) ? $run->context : [];
$context['restore_run_id'] = (int) $queuedRestoreRun->getKey();
$run->forceFill(['context' => $context])->save();
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $queuedRestoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $queuedRestoreRun->id,
status: 'success',
);
if ($existingOpRun) {
$toast->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$providerConnectionId = is_numeric($context['provider_connection_id'] ?? null)
? (int) $context['provider_connection_id']
: null;
$toast->send();
ExecuteRestoreRunJob::dispatch(
restoreRunId: (int) $queuedRestoreRun->getKey(),
actorEmail: $actorEmail,
actorName: $actorName,
operationRun: $run,
providerConnectionId: $providerConnectionId,
)->afterCommit();
};
return $existing;
if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'restore.execute',
dispatcher: $dispatcher,
initiator: $initiator,
extraContext: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
);
} else {
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant,
type: 'restore.execute',
identityInputs: [
'idempotency_key' => $idempotencyKey,
],
context: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
],
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
$dispatcher($run);
$result = ProviderOperationStartResult::started($run, true);
} else {
$result = ProviderOperationStartResult::deduped($run);
}
}
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') {
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
if (is_numeric($restoreRunId)) {
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first();
}
throw $exception;
$queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun(
(int) $tenant->getKey(),
$idempotencyKey,
);
}
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success',
);
return [$result, $queuedRestoreRun?->refresh()];
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
/**
* @param array<int>|null $selectedItemIds
*/
private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
{
$query = $backupSet->items()->select(['id', 'policy_type']);
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
if (is_array($selectedItemIds) && $selectedItemIds !== []) {
$query->whereIn('id', $selectedItemIds);
}
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
return $query->get()->contains(function (BackupItem $item): bool {
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return $restoreRun->refresh();
return $restoreMode !== 'preview-only';
});
}
/**
@ -2452,122 +2527,34 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$metadata['rerun_of_restore_run_id'] = $record->id;
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
throw $exception;
}
$auditLogger->log(
[$result, $newRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) {
return;
}
$auditLogger->log(
tenant: $tenant,
@ -2585,15 +2572,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
actorName: $actorName,
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}

View File

@ -399,9 +399,12 @@ private static function truthEnvelope(ReviewPack $record, bool $fresh = false):
private static function truthState(ReviewPack $record, bool $fresh = false): array
{
$presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? static::truthEnvelope($record, true)
: static::truthEnvelope($record);
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
?? $truth->toArray(static::compressedOutcome($record, $fresh));
}
private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome

View File

@ -42,6 +42,7 @@
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
@ -513,20 +514,16 @@ private static function handleVerifyConfigurationAction(
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $runUrl,
);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
return;
}
@ -534,68 +531,20 @@ private static function handleVerifyConfigurationAction(
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
return;
}
if ($result->status === 'blocked') {
$actions = [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
$notification->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
}
private static function userCanManageAnyTenant(User $user): bool
@ -3319,29 +3268,14 @@ public static function syncRoleDefinitionsAction(): Actions\Action
abort(403);
}
$opRun = $syncService->startManualSync($record, $user);
$result = $syncService->startManualSync($record, $user);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Role definitions sync blocked',
runUrl: OperationRunLinks::tenantlessView($result->run),
);
$runUrl = OperationRunLinks::tenantlessView($opRun);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
OperationUxPresenter::queuedToast('directory_role_definitions.sync')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
});
}
}

View File

@ -17,6 +17,7 @@
use App\Support\OperationRunLinks;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -71,20 +72,16 @@ public function startVerification(StartVerification $verification): void
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $runUrl,
);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
return;
}
@ -92,72 +89,20 @@ public function startVerification(StartVerification $verification): void
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
$notification->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
$notification->send();
}
/**

View File

@ -121,6 +121,10 @@ public function handle(Request $request, Closure $next): Response
reason: 'single_membership',
);
if ($this->requestHasExplicitTenantContext($request)) {
return $next($request);
}
return $this->redirectViaTenantBranching($workspace, $user);
}
}
@ -144,6 +148,10 @@ public function handle(Request $request, Closure $next): Response
reason: 'last_used',
);
if ($this->requestHasExplicitTenantContext($request)) {
return $next($request);
}
return $this->redirectViaTenantBranching($lastWorkspace, $user);
}
@ -203,6 +211,17 @@ private function isChooserFirstPath(string $path): bool
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
}
private function requestHasExplicitTenantContext(Request $request): bool
{
if (filled($request->query('tenant')) || filled($request->query('tenant_id'))) {
return true;
}
$route = $request->route();
return $route?->hasParameter('tenant') && filled($route->parameter('tenant'));
}
private function redirectToChooser(): Response
{
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']);

View File

@ -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;
/**

View File

@ -31,6 +31,7 @@ public function __construct(
public string $selectionKey,
public ?string $slotKey = null,
public ?int $runId = null,
public ?int $providerConnectionId = null,
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
@ -74,7 +75,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
resourceId: (string) $this->operationRun->getKey(),
);
$result = $syncService->sync($tenant, $this->selectionKey);
$result = $syncService->sync($tenant, $this->selectionKey, $this->providerConnectionId());
$terminalStatus = 'succeeded';
@ -133,4 +134,16 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
resourceId: (string) $this->operationRun->getKey(),
);
}
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
}

View File

@ -36,6 +36,7 @@ public function __construct(
public ?string $actorEmail = null,
public ?string $actorName = null,
?OperationRun $operationRun = null,
public ?int $providerConnectionId = null,
) {
$this->operationRun = $operationRun;
}
@ -160,12 +161,15 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
);
try {
$providerConnectionId = $this->providerConnectionId();
$restoreService->executeForRun(
restoreRun: $restoreRun,
tenant: $tenant,
backupSet: $backupSet,
actorEmail: $this->actorEmail,
actorName: $this->actorName,
providerConnectionId: $providerConnectionId,
);
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
@ -207,4 +211,16 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
throw $throwable;
}
}
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
}

View File

@ -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;
/**

View File

@ -31,6 +31,7 @@ class SyncRoleDefinitionsJob implements ShouldQueue
*/
public function __construct(
public int $tenantId,
public ?int $providerConnectionId = null,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
@ -69,7 +70,7 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
resourceId: (string) $this->operationRun->getKey(),
);
$result = $syncService->sync($tenant);
$result = $syncService->sync($tenant, $this->providerConnectionId());
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
@ -124,4 +125,16 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
resourceId: (string) $this->operationRun->getKey(),
);
}
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
}

View File

@ -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),
],

View File

@ -4,13 +4,16 @@
namespace App\Livewire;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@ -235,7 +238,7 @@ private function resolveInventoryItem(): InventoryItem
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
$tenant = $this->resolveCurrentTenant();
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
if (! $this->canViewInventoryItem($inventoryItem, $tenant)) {
throw new NotFoundHttpException;
}
@ -246,6 +249,10 @@ private function resolveCurrentTenant(): Tenant
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
$tenant = app(WorkspaceContext::class)->rememberedTenant(request());
}
if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
@ -253,6 +260,21 @@ private function resolveCurrentTenant(): Tenant
return $tenant;
}
private function canViewInventoryItem(InventoryItem $inventoryItem, Tenant $tenant): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$capabilityResolver = app(CapabilityResolver::class);
return (int) $inventoryItem->tenant_id === (int) $tenant->getKey()
&& $capabilityResolver->isMember($user, $tenant)
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
private function normalizeRelationshipType(mixed $value): ?string
{
if (! is_string($value) || $value === '' || $value === 'all') {

View File

@ -41,6 +41,14 @@ private function assignmentReferences(): array
$tenant = rescue(fn () => Tenant::current(), null);
if (! $tenant instanceof Tenant) {
$this->version->loadMissing('tenant');
$tenant = $this->version->tenant instanceof Tenant
? $this->version->tenant
: null;
}
$groupIds = [];
$sourceNames = [];

View File

@ -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([

View File

@ -225,6 +225,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows],
description: $rendered->fidelitySummary,
emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
),
$factory->viewSection(
@ -262,6 +263,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage',
items: [
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Fidelity mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
],

View File

@ -10,8 +10,10 @@
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable;
class EntraGroupSyncService
@ -20,38 +22,38 @@ public function __construct(
private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProviderOperationStartGate $providerStarts,
) {}
public function startManualSync(Tenant $tenant, User $user): OperationRun
public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult
{
$selectionKey = EntraGroupSelection::allGroupsV1();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
return $this->providerStarts->start(
tenant: $tenant,
type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
connection: null,
operationType: 'entra_group_sync',
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id']
: null;
EntraGroupSyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
providerConnectionId: $providerConnectionId,
operationRun: $run,
)->afterCommit();
},
initiator: $user,
extraContext: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
'required_capability' => Capabilities::TENANT_SYNC,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
operationRun: $opRun,
));
return $opRun;
}
/**
@ -67,7 +69,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
* error_summary:?string
* }
*/
public function sync(Tenant $tenant, string $selectionKey): array
public function sync(Tenant $tenant, string $selectionKey, ?int $providerConnectionId = null): array
{
$nowUtc = CarbonImmutable::now('UTC');
@ -105,7 +107,9 @@ public function sync(Tenant $tenant, string $selectionKey): array
$errorSummary = null;
$errorCount = 0;
$options = $this->graphOptionsResolver->resolveForTenant($tenant);
$options = $providerConnectionId !== null
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
: $this->graphOptionsResolver->resolveForTenant($tenant);
$useQuery = $query;
$nextPath = $path;

View File

@ -10,8 +10,10 @@
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable;
class RoleDefinitionsSyncService
@ -20,36 +22,35 @@ public function __construct(
private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProviderOperationStartGate $providerStarts,
) {}
public function startManualSync(Tenant $tenant, User $user): OperationRun
public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult
{
$selectionKey = 'role_definitions_v1';
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
return $this->providerStarts->start(
tenant: $tenant,
type: 'directory_role_definitions.sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
connection: null,
operationType: 'directory_role_definitions.sync',
dispatcher: function (OperationRun $run) use ($tenant): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id']
: null;
SyncRoleDefinitionsJob::dispatch(
tenantId: (int) $tenant->getKey(),
providerConnectionId: $providerConnectionId,
operationRun: $run,
)->afterCommit();
},
initiator: $user,
extraContext: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new SyncRoleDefinitionsJob(
tenantId: (int) $tenant->getKey(),
operationRun: $opRun,
));
return $opRun;
}
/**
@ -65,7 +66,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
* error_summary:?string
* }
*/
public function sync(Tenant $tenant): array
public function sync(Tenant $tenant, ?int $providerConnectionId = null): array
{
$nowUtc = CarbonImmutable::now('UTC');
@ -103,7 +104,9 @@ public function sync(Tenant $tenant): array
$errorSummary = null;
$errorCount = 0;
$options = $this->graphOptionsResolver->resolveForTenant($tenant);
$options = $providerConnectionId !== null
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
: $this->graphOptionsResolver->resolveForTenant($tenant);
$useQuery = $query;
$nextPath = $path;

View File

@ -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,

View File

@ -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

View File

@ -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]);

View File

@ -236,6 +236,7 @@ public function executeForRun(
BackupSet $backupSet,
?string $actorEmail = null,
?string $actorName = null,
?int $providerConnectionId = null,
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
@ -266,6 +267,7 @@ public function executeForRun(
actorName: $actorName,
groupMapping: $restoreRun->group_mapping ?? [],
existingRun: $restoreRun,
providerConnectionId: $providerConnectionId,
);
}
@ -286,6 +288,7 @@ public function execute(
?string $actorName = null,
array $groupMapping = [],
?RestoreRun $existingRun = null,
?int $providerConnectionId = null,
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
@ -297,7 +300,7 @@ public function execute(
$baseGraphOptions = [];
if (! $dryRun) {
$connection = $this->resolveProviderConnection($tenant);
$connection = $this->resolveProviderConnection($tenant, $providerConnectionId);
$tenantIdentifier = (string) $connection->entra_tenant_id;
$baseGraphOptions = $this->providerGateway()->graphOptions($connection);
}
@ -2910,9 +2913,23 @@ private function buildScopeTagsForVersion(
];
}
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
private function resolveProviderConnection(Tenant $tenant, ?int $providerConnectionId = null): ProviderConnection
{
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
if ($providerConnectionId !== null) {
$connection = ProviderConnection::query()->find($providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is not configured.',
));
}
$resolution = $this->providerConnections()->validateConnection($tenant, 'microsoft', $connection);
} else {
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
}
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection;

View File

@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self
{
$maskedId = static::mask($targetId);
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null;
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null;
return new self(
targetLabel: $label,

View File

@ -2,7 +2,9 @@
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Providers\ProviderReasonCodes;
final class MicrosoftGraphOptionsResolver
{
@ -28,4 +30,37 @@ public function resolveForTenant(Tenant $tenant, array $overrides = []): array
return $this->gateway->graphOptions($resolution->connection, $overrides);
}
/**
* @return array<string, mixed>
*/
public function resolveForConnection(Tenant $tenant, int|ProviderConnection $connection, array $overrides = []): array
{
$providerConnection = $connection instanceof ProviderConnection
? $connection
: ProviderConnection::query()->find($connection);
if (! $providerConnection instanceof ProviderConnection) {
throw ProviderConfigurationRequiredException::forTenant(
tenant: $tenant,
provider: 'microsoft',
resolution: ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'The selected provider connection could not be found.',
),
);
}
$resolution = $this->connections->validateConnection($tenant, 'microsoft', $providerConnection);
if (! $resolution->resolved || $resolution->connection === null) {
throw ProviderConfigurationRequiredException::forTenant(
tenant: $tenant,
provider: 'microsoft',
resolution: $resolution,
);
}
return $this->gateway->graphOptions($resolution->connection, $overrides);
}
}

View File

@ -2,12 +2,13 @@
namespace App\Services\Providers;
use App\Support\Auth\Capabilities;
use InvalidArgumentException;
final class ProviderOperationRegistry
{
/**
* @return array<string, array{provider: string, module: string, label: string}>
* @return array<string, array{provider: string, module: string, label: string, required_capability: string}>
*/
public function all(): array
{
@ -16,16 +17,37 @@ public function all(): array
'provider' => 'microsoft',
'module' => 'health_check',
'label' => 'Provider connection check',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'inventory_sync' => [
'provider' => 'microsoft',
'module' => 'inventory',
'label' => 'Inventory sync',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'compliance.snapshot' => [
'provider' => 'microsoft',
'module' => 'compliance',
'label' => 'Compliance snapshot',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'restore.execute' => [
'provider' => 'microsoft',
'module' => 'restore',
'label' => 'Restore execution',
'required_capability' => Capabilities::TENANT_MANAGE,
],
'entra_group_sync' => [
'provider' => 'microsoft',
'module' => 'directory_groups',
'label' => 'Directory groups sync',
'required_capability' => Capabilities::TENANT_SYNC,
],
'directory_role_definitions.sync' => [
'provider' => 'microsoft',
'module' => 'directory_role_definitions',
'label' => 'Role definitions sync',
'required_capability' => Capabilities::TENANT_MANAGE,
],
];
}
@ -36,7 +58,7 @@ public function isAllowed(string $operationType): bool
}
/**
* @return array{provider: string, module: string, label: string}
* @return array{provider: string, module: string, label: string, required_capability: string}
*/
public function get(string $operationType): array
{

View File

@ -244,6 +244,15 @@ private function resolveRequiredCapability(string $operationType, array $extraCo
return trim((string) $extraContext['required_capability']);
}
if ($this->registry->isAllowed($operationType)) {
$definition = $this->registry->get($operationType);
$requiredCapability = $definition['required_capability'] ?? null;
if (is_string($requiredCapability) && trim($requiredCapability) !== '') {
return trim($requiredCapability);
}
}
if ($this->registry->isAllowed($operationType)) {
return Capabilities::PROVIDER_RUN;
}

View File

@ -86,6 +86,10 @@ public function handle(Request $request, Closure $next): Response
! $resolvedContext->hasTenant()
&& $this->adminPathRequiresTenantSelection($path)
) {
if ($this->requestHasExplicitTenantHint($request)) {
abort(404);
}
return redirect()->route('filament.admin.pages.choose-tenant');
}
@ -232,12 +236,21 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
}
private function requestHasExplicitTenantHint(Request $request): bool
{
return filled($request->query('tenant')) || filled($request->query('tenant_id'));
}
private function adminPathRequiresTenantSelection(string $path): bool
{
if (! str_starts_with($path, '/admin/')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
}
}

View File

@ -119,7 +119,7 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
$pageCategory = $this->pageCategory($request);
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
$workspace = $this->resolveWorkspaceForPageCategory($routeTenantCandidate, $pageCategory, $request);
$workspaceSource = match (true) {
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
@ -185,6 +185,19 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
$recoveryReason ??= $queryHintTenant['reason'];
if ($this->requiresStrictQueryTenantHintResolution($request)) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'invalid_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: 'abort_not_found',
recoveryReason: $recoveryReason ?? 'missing_tenant',
);
}
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
if ($tenant instanceof Tenant) {
@ -256,7 +269,7 @@ private function resolveValidatedFilamentTenant(
}
$pageCategory ??= $this->pageCategory($request);
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
$workspace ??= $this->resolveWorkspaceForPageCategory($tenant, $pageCategory, $request);
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
return $tenant;
@ -288,6 +301,18 @@ private function resolveValidatedRouteTenant(
return ['tenant' => $tenant, 'reason' => null];
}
private function resolveWorkspaceForPageCategory(
?Tenant $tenant,
TenantPageCategory $pageCategory,
?Request $request = null,
): ?Workspace {
return match ($pageCategory) {
TenantPageCategory::TenantScopedEvidence => $this->workspaceContext->currentWorkspace($request),
TenantPageCategory::TenantBound => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
default => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
};
}
private function resolveValidatedQueryHintTenant(
?Request $request,
Workspace $workspace,
@ -349,6 +374,30 @@ private function resolveQueryTenantHint(?Request $request = null): ?Tenant
return null;
}
private function hasExplicitQueryTenantHint(?Request $request = null): bool
{
return filled($request?->query('tenant')) || filled($request?->query('tenant_id'));
}
private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool
{
if (! $this->hasExplicitQueryTenantHint($request)) {
return false;
}
$path = '/'.ltrim((string) $request?->path(), '/');
if (! str_starts_with($path, '/admin/')) {
return false;
}
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
}
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
{
if ($tenantIdentifier instanceof Tenant) {

View File

@ -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.'),

View File

@ -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;

View File

@ -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',

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use InvalidArgumentException;
final readonly class GovernanceRunDiagnosticSummary
{
/**
* @param array{label: string, value: string, source: string, confidence?: string}|null $affectedScaleCue
* @param array{
* code: ?string,
* label: string,
* explanation: string
* } $dominantCause
* @param list<array{
* code: ?string,
* label: string,
* explanation: string
* }> $secondaryCauses
* @param list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }> $secondaryFacts
*/
public function __construct(
public string $headline,
public string $executionOutcomeLabel,
public string $artifactImpactLabel,
public string $primaryReason,
public ?array $affectedScaleCue,
public string $nextActionCategory,
public string $nextActionText,
public array $dominantCause,
public array $secondaryCauses = [],
public array $secondaryFacts = [],
public bool $diagnosticsAvailable = false,
) {
foreach ([
'headline' => $this->headline,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'artifactImpactLabel' => $this->artifactImpactLabel,
'primaryReason' => $this->primaryReason,
'nextActionCategory' => $this->nextActionCategory,
'nextActionText' => $this->nextActionText,
] as $field => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException("Governance run summaries require {$field}.");
}
}
if (trim((string) ($this->dominantCause['label'] ?? '')) === '' || trim((string) ($this->dominantCause['explanation'] ?? '')) === '') {
throw new InvalidArgumentException('Governance run summaries require a dominant cause label and explanation.');
}
}
/**
* @return array{
* headline: string,
* executionOutcomeLabel: string,
* artifactImpactLabel: string,
* primaryReason: string,
* affectedScaleCue: array{label: string, value: string, source: string, confidence?: string}|null,
* nextActionCategory: string,
* nextActionText: string,
* dominantCause: array{code: ?string, label: string, explanation: string},
* secondaryCauses: list<array{code: ?string, label: string, explanation: string}>,
* secondaryFacts: list<array{label: string, value: string, hint?: ?string, emphasis?: string}>,
* diagnosticsAvailable: bool
* }
*/
public function toArray(): array
{
return [
'headline' => $this->headline,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'artifactImpactLabel' => $this->artifactImpactLabel,
'primaryReason' => $this->primaryReason,
'affectedScaleCue' => $this->affectedScaleCue,
'nextActionCategory' => $this->nextActionCategory,
'nextActionText' => $this->nextActionText,
'dominantCause' => $this->dominantCause,
'secondaryCauses' => $this->secondaryCauses,
'secondaryFacts' => $this->secondaryFacts,
'diagnosticsAvailable' => $this->diagnosticsAvailable,
];
}
}

View File

@ -0,0 +1,913 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationCatalog;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
final class GovernanceRunDiagnosticSummaryBuilder
{
public function __construct(
private readonly ArtifactTruthPresenter $artifactTruthPresenter,
private readonly ReasonPresenter $reasonPresenter,
) {}
public function build(
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth = null,
?OperatorExplanationPattern $operatorExplanation = null,
?ReasonResolutionEnvelope $reasonEnvelope = null,
): ?GovernanceRunDiagnosticSummary {
if (! $run->supportsOperatorExplanation()) {
return null;
}
$artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run);
$operatorExplanation ??= $artifactTruth?->operatorExplanation;
$reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail');
if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) {
return null;
}
$canonicalType = OperationCatalog::canonicalCode((string) $run->type);
$context = is_array($run->context) ? $run->context : [];
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
$causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context);
$dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope);
$secondaryCauses = array_values(array_slice($causeCandidates, 1));
$artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation);
$headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts);
$primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope);
$nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context);
$nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope);
$affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts);
$secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText);
return new GovernanceRunDiagnosticSummary(
headline: $headline,
executionOutcomeLabel: $this->executionOutcomeLabel($run),
artifactImpactLabel: $artifactImpactLabel,
primaryReason: $primaryReason,
affectedScaleCue: $affectedScaleCue,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
dominantCause: [
'code' => $dominantCause['code'] ?? null,
'label' => $dominantCause['label'],
'explanation' => $dominantCause['explanation'],
],
secondaryCauses: array_map(
static fn (array $cause): array => [
'code' => $cause['code'] ?? null,
'label' => $cause['label'],
'explanation' => $cause['explanation'],
],
$secondaryCauses,
),
secondaryFacts: $secondaryFacts,
diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false),
);
}
private function executionOutcomeLabel(OperationRun $run): string
{
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome);
return $spec->label !== 'Unknown'
? $spec->label
: ucfirst(str_replace('_', ' ', trim((string) $run->outcome)));
}
private function artifactImpactLabel(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): string {
if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') {
return $artifactTruth->primaryLabel;
}
if ($operatorExplanation instanceof OperatorExplanationPattern) {
return $operatorExplanation->trustworthinessLabel();
}
return 'Result needs review';
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function headline(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $dominantCause,
array $context,
array $counts,
): string {
return match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation),
'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation),
'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation),
'tenant.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation),
default => $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'This governance run needs review before it can be relied on.',
};
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function baselineCaptureHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $context,
array $counts,
?OperatorExplanationPattern $operatorExplanation,
): string {
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
if ($subjectsTotal === 0) {
return 'No baseline was captured because no governed subjects were ready.';
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'The baseline capture started, but more evidence still needs to be collected.';
}
if ($gapCount > 0) {
return 'The baseline capture finished, but evidence gaps still limit the snapshot.';
}
if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') {
return 'The baseline capture finished without a usable snapshot.';
}
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
return 'The baseline capture finished without producing a decision-grade snapshot.';
}
return $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The baseline capture needs review before it can be used.';
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function baselineCompareHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $context,
array $counts,
?OperatorExplanationPattern $operatorExplanation,
): string {
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
$proof = data_get($context, 'baseline_compare.coverage.proof');
$resumeToken = data_get($context, 'baseline_compare.resume_token');
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
return 'The compare finished, but ambiguous subject matching limited the result.';
}
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
return 'The compare finished, but a compare strategy failure kept the result incomplete.';
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'The compare finished, but evidence capture still needs to resume before the result is complete.';
}
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
return 'The compare finished, but no decision-grade result is available yet.';
}
if ($proof === false) {
return 'The compare finished, but missing coverage proof suppressed the normal result.';
}
return $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The compare needs follow-up before it can be treated as complete.';
}
private function evidenceSnapshotHeadline(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.',
$artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.',
$artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.',
default => $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The evidence snapshot needs review before it is relied on.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function reviewComposeHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $dominantCause,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale'
=> 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
$artifactTruth?->contentState === 'partial'
=> 'The review was generated, but required sections are still incomplete.',
$artifactTruth?->freshnessState === 'stale'
=> 'The review was generated, but it relies on stale evidence.',
default => $operatorExplanation?->headline
?? $dominantCause['explanation']
?? 'The review needs follow-up before it should guide action.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function reviewPackHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $dominantCause,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->publicationReadiness === 'blocked'
=> 'The pack did not produce a shareable artifact yet.',
$artifactTruth?->publicationReadiness === 'internal_only'
=> 'The pack finished, but it should stay internal until the source review is refreshed.',
default => $operatorExplanation?->headline
?? $dominantCause['explanation']
?? 'The review pack needs follow-up before it is shared.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function primaryReason(
array $dominantCause,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): string {
return $dominantCause['explanation']
?? $operatorExplanation?->dominantCauseExplanation
?? $reasonEnvelope?->shortExplanation
?? $artifactTruth?->primaryExplanation
?? $operatorExplanation?->reliabilityStatement
?? 'TenantPilot recorded diagnostic detail for this run.';
}
/**
* @param array<string, mixed> $context
*/
private function nextActionCategory(
string $canonicalType,
OperationRun $run,
?ReasonResolutionEnvelope $reasonEnvelope,
?OperatorExplanationPattern $operatorExplanation,
array $context,
): string {
if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') {
return 'retry_later';
}
if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) {
$resumeToken = $canonicalType === 'baseline.capture'
? data_get($context, 'baseline_capture.resume_token')
: data_get($context, 'baseline_compare.resume_token');
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'resume_capture_or_generation';
}
}
$reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? '');
if (in_array($reasonCode, [
BaselineCompareReasonCode::AmbiguousSubjects->value,
BaselineCompareReasonCode::UnsupportedSubjects->value,
], true)) {
return 'review_scope_or_ambiguous_matches';
}
if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) {
return 'refresh_prerequisite_data';
}
if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') {
return 'no_further_action';
}
if (
$reasonEnvelope?->actionability === 'prerequisite_missing'
|| in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'tenant.review.compose', 'tenant.review_pack.generate'], true)
) {
return 'refresh_prerequisite_data';
}
return 'manually_validate';
}
private function nextActionText(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): string {
$text = $operatorExplanation?->nextActionText
?? $artifactTruth?->nextStepText()
?? $reasonEnvelope?->firstNextStep()?->label
?? 'No action needed';
return trim(rtrim($text, '.')).'.';
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function affectedScaleCue(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $context,
array $counts,
): ?array {
return match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts),
'baseline.compare' => $this->baselineCompareScaleCue($context, $counts),
'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']),
'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
'tenant.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
default => $this->summaryCountsScaleCue($counts),
};
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function baselineCaptureScaleCue(array $context, array $counts): ?array
{
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
if ($gapCount > 0) {
return [
'label' => 'Affected subjects',
'value' => "{$gapCount} governed subjects still need evidence follow-up.",
'source' => 'context',
'confidence' => 'exact',
];
}
if ($subjectsTotal >= 0) {
return [
'label' => 'Capture scope',
'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.",
'source' => 'context',
'confidence' => 'exact',
];
}
return $this->summaryCountsScaleCue($counts);
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function baselineCompareScaleCue(array $context, array $counts): ?array
{
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total'));
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
if ($gapCount > 0) {
return [
'label' => 'Affected subjects',
'value' => "{$gapCount} governed subjects still have evidence gaps.",
'source' => 'context',
'confidence' => 'exact',
];
}
if ($uncoveredTypes !== []) {
$count = count($uncoveredTypes);
return [
'label' => 'Coverage scope',
'value' => "{$count} policy types were left without proven compare coverage.",
'source' => 'context',
'confidence' => 'bounded',
];
}
if ($subjectsTotal > 0) {
return [
'label' => 'Compare scope',
'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.",
'source' => 'context',
'confidence' => 'exact',
];
}
return $this->summaryCountsScaleCue($counts);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
{
if ($artifactTruth?->contentState === 'partial') {
$sections = $this->findCountDescriptor($countDescriptors, 'Sections');
if ($sections instanceof CountDescriptor) {
return [
'label' => 'Review sections',
'value' => "{$sections->value} sections were recorded and still need review for completeness.",
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return [
'label' => 'Review sections',
'value' => 'Required review sections are still incomplete.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
if ($artifactTruth?->freshnessState === 'stale') {
return [
'label' => 'Evidence freshness',
'value' => 'The source evidence is stale for at least part of this review.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
{
if ($artifactTruth?->publicationReadiness === 'internal_only') {
return [
'label' => 'Sharing scope',
'value' => 'The pack is suitable for internal follow-up only in its current state.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @param list<string> $preferredLabels
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array
{
foreach ($preferredLabels as $label) {
$descriptor = $this->findCountDescriptor($countDescriptors, $label);
if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) {
continue;
}
return [
'label' => $descriptor->label,
'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.",
'source' => 'related_artifact_truth',
'confidence' => 'exact',
];
}
return null;
}
/**
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function summaryCountsScaleCue(array $counts): ?array
{
foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) {
$value = (int) ($counts[$key] ?? 0);
if ($value <= 0) {
continue;
}
return [
'label' => SummaryCountsNormalizer::label($key),
'value' => "{$value} recorded in the canonical run counters.",
'source' => 'summary_counts',
'confidence' => 'exact',
];
}
return null;
}
/**
* @param array<string, mixed> $context
* @return list<array{rank: int, code: ?string, label: string, explanation: string}>
*/
private function rankCauseCandidates(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $context,
): array {
$candidates = [];
$this->pushCandidate(
$candidates,
code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel,
explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation,
rank: $this->reasonRank($reasonEnvelope, $operatorExplanation),
);
match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context),
'baseline.compare' => $this->baselineCompareCandidates($candidates, $context),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation),
'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth),
'tenant.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth),
default => null,
};
usort($candidates, static function (array $left, array $right): int {
$rank = ($right['rank'] <=> $left['rank']);
if ($rank !== 0) {
return $rank;
}
return strcmp($left['label'], $right['label']);
});
return array_values(array_map(
static fn (array $candidate): array => [
'code' => $candidate['code'],
'label' => $candidate['label'],
'explanation' => $candidate['explanation'],
],
$candidates,
));
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void
{
$label = is_string($label) ? trim($label) : '';
$explanation = is_string($explanation) ? trim($explanation) : '';
if ($label === '' || $explanation === '') {
return;
}
foreach ($candidates as $candidate) {
if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) {
return;
}
}
$candidates[] = [
'code' => $code,
'label' => $label,
'explanation' => $explanation,
'rank' => $rank,
];
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $context
*/
private function baselineCaptureCandidates(array &$candidates, array $context): void
{
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
if ($subjectsTotal === 0) {
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
}
if ($gapCount > 0) {
$this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82);
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
$this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $context
*/
private function baselineCompareCandidates(array &$candidates, array $context): void
{
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
$proof = data_get($context, 'baseline_compare.coverage.proof');
$resumeToken = data_get($context, 'baseline_compare.resume_token');
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
$this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92);
}
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
$this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94);
}
if ($gapCount > 0) {
$this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83);
}
if ($proof === false || $uncoveredTypes !== []) {
$count = count($uncoveredTypes);
$explanation = $count > 0
? "{$count} policy types were left without proven compare coverage."
: 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.';
$this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81);
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
$this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<int, CountDescriptor> $countDescriptors
*/
private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void
{
$countDescriptors = $operatorExplanation?->countDescriptors ?? [];
$missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions');
$stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions');
if ($missing instanceof CountDescriptor && $missing->value > 0) {
$this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88);
}
if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) {
$value = $stale instanceof CountDescriptor && $stale->value > 0
? "{$stale->value} evidence dimensions are stale and should be refreshed."
: 'Part of the evidence basis is stale and should be refreshed before use.';
$this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
{
if ($artifactTruth?->contentState === 'partial') {
$this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90);
}
if ($artifactTruth?->freshnessState === 'stale') {
$this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86);
}
if ($artifactTruth?->publicationReadiness === 'blocked') {
$this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
{
if ($artifactTruth?->publicationReadiness === 'blocked') {
$this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94);
}
if ($artifactTruth?->publicationReadiness === 'internal_only') {
$this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80);
}
if ($artifactTruth?->freshnessState === 'stale') {
$this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84);
}
if ($artifactTruth?->contentState === 'partial') {
$this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86);
}
}
private function reasonRank(
?ReasonResolutionEnvelope $reasonEnvelope,
?OperatorExplanationPattern $operatorExplanation,
): int {
if ($reasonEnvelope?->actionability === 'retryable_transient') {
return 76;
}
return match ($operatorExplanation?->nextActionCategory) {
'fix_prerequisite' => 92,
'retry_later' => 76,
'none' => 40,
default => 85,
};
}
/**
* @param list<array{code: ?string, label: string, explanation: string}> $secondaryCauses
* @return list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }>
*/
private function secondaryFacts(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $secondaryCauses,
string $nextActionCategory,
string $nextActionText,
): array {
$facts = [];
if ($operatorExplanation instanceof OperatorExplanationPattern) {
$facts[] = [
'label' => 'Result trust',
'value' => $operatorExplanation->trustworthinessLabel(),
'hint' => $this->deduplicateSecondaryFactHint(
$operatorExplanation->reliabilityStatement,
$operatorExplanation->dominantCauseExplanation,
$artifactTruth?->primaryExplanation,
),
'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value),
];
if ($operatorExplanation->evaluationResultLabel() !== '') {
$facts[] = [
'label' => 'Result meaning',
'value' => $operatorExplanation->evaluationResultLabel(),
'hint' => $operatorExplanation->coverageStatement,
'emphasis' => 'neutral',
];
}
}
if ($secondaryCauses !== []) {
$facts[] = [
'label' => 'Secondary causes',
'value' => implode(' · ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)),
'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.',
'emphasis' => 'caution',
];
}
if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') {
$facts[] = [
'label' => 'Related artifact access',
'value' => 'No related artifact link is available from this run.',
'emphasis' => 'neutral',
];
}
return $facts;
}
private function emphasisFromTrust(string $trust): string
{
return match ($trust) {
'unusable' => 'blocked',
'diagnostic_only', 'limited_confidence' => 'caution',
default => 'neutral',
};
}
private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string
{
$normalizedHint = $this->normalizeFactText($hint);
if ($normalizedHint === null) {
return null;
}
foreach ($duplicates as $duplicate) {
if ($normalizedHint === $this->normalizeFactText($duplicate)) {
return null;
}
}
return trim($hint ?? '');
}
private function fallbackCause(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): array {
return [
'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
'label' => $reasonEnvelope?->operatorLabel
?? $operatorExplanation?->dominantCauseLabel
?? $artifactTruth?->primaryLabel
?? 'Follow-up required',
'explanation' => $reasonEnvelope?->shortExplanation
?? $operatorExplanation?->dominantCauseExplanation
?? $artifactTruth?->primaryExplanation
?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.',
];
}
private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor
{
foreach ($countDescriptors as $descriptor) {
if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) {
return $descriptor;
}
}
return null;
}
private function intValue(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function pluralizeDescriptor(CountDescriptor $descriptor): string
{
return match ($descriptor->label) {
'Missing dimensions' => 'evidence dimensions are missing',
'Stale dimensions' => 'evidence dimensions are stale',
'Evidence dimensions' => 'evidence dimensions were recorded',
'Sections' => 'sections were recorded',
'Reports' => 'reports were recorded',
'Findings' => 'findings were recorded',
default => strtolower($descriptor->label).' were recorded',
};
}
private function normalizeFactText(?string $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
if ($normalized === '') {
return null;
}
return mb_strtolower($normalized);
}
}

View File

@ -53,6 +53,34 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/**
* Canonical provider-backed dedupe feedback using the shared start vocabulary.
*/
public static function alreadyRunningToast(string $operationType): FilamentNotification
{
$operationLabel = OperationCatalog::label($operationType);
return FilamentNotification::make()
->title("{$operationLabel} already running")
->body('A matching operation is already queued or running. Open the operation for progress and next steps.')
->info()
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/**
* Canonical provider-backed protected-scope conflict feedback.
*/
public static function scopeBusyToast(
string $title = 'Scope busy',
string $body = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification {
return FilamentNotification::make()
->title($title)
->body($body)
->warning()
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/**
* Terminal DB notification payload.
*
@ -322,6 +350,16 @@ public static function governanceOperatorExplanation(OperationRun $run): ?Operat
return self::resolveGovernanceOperatorExplanation($run);
}
public static function governanceDiagnosticSummary(OperationRun $run): ?GovernanceRunDiagnosticSummary
{
return self::resolveGovernanceDiagnosticSummary($run);
}
public static function governanceDiagnosticSummaryFresh(OperationRun $run): ?GovernanceRunDiagnosticSummary
{
return self::resolveGovernanceDiagnosticSummary($run, fresh: true);
}
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
{
return self::resolveGovernanceOperatorExplanation($run, fresh: true);
@ -464,6 +502,29 @@ private static function resolveGovernanceOperatorExplanation(OperationRun $run,
);
}
private static function resolveGovernanceDiagnosticSummary(OperationRun $run, bool $fresh = false): ?GovernanceRunDiagnosticSummary
{
if (! $run->supportsOperatorExplanation()) {
return null;
}
return self::memoizeExplanation(
run: $run,
variant: 'governance_diagnostic_summary',
resolver: fn (): ?GovernanceRunDiagnosticSummary => app(GovernanceRunDiagnosticSummaryBuilder::class)->build(
run: $run,
artifactTruth: $fresh
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)
: app(ArtifactTruthPresenter::class)->forOperationRun($run),
operatorExplanation: $fresh
? self::resolveGovernanceOperatorExplanation($run, fresh: true)
: self::resolveGovernanceOperatorExplanation($run),
reasonEnvelope: app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
),
fresh: $fresh,
);
}
private static function memoizeGuidance(
OperationRun $run,
string $variant,

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\OperationRunLinks;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonPresenter;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
final class ProviderOperationStartResultPresenter
{
public function __construct(
private readonly ReasonPresenter $reasonPresenter,
) {}
/**
* @param array<int, Action> $extraActions
*/
public function notification(
ProviderOperationStartResult $result,
string $blockedTitle,
string $runUrl,
array $extraActions = [],
string $scopeBusyTitle = 'Scope busy',
string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification {
$notification = match ($result->status) {
'started' => OperationUxPresenter::queuedToast((string) $result->run->type),
'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type),
'scope_busy' => OperationUxPresenter::scopeBusyToast($scopeBusyTitle, $scopeBusyBody),
'blocked' => FilamentNotification::make()
->title($blockedTitle)
->body(implode("\n", $this->blockedBodyLines($result)))
->warning(),
default => OperationUxPresenter::queuedToast((string) $result->run->type),
};
return $notification->actions($this->actionsFor($result, $runUrl, $extraActions));
}
/**
* @param array<int, Action> $extraActions
* @return array<int, Action>
*/
private function actionsFor(ProviderOperationStartResult $result, string $runUrl, array $extraActions): array
{
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
if ($result->status === 'blocked') {
$nextStep = $this->firstNextStep($result);
if ($nextStep instanceof NextStepOption && $nextStep->kind === 'link' && is_string($nextStep->destination) && trim($nextStep->destination) !== '') {
$actions[] = Action::make('next_step_0')
->label($nextStep->label)
->url($nextStep->destination);
}
}
return [...$actions, ...$extraActions];
}
/**
* @return array<int, string>
*/
private function blockedBodyLines(ProviderOperationStartResult $result): array
{
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
return $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
}
private function firstNextStep(ProviderOperationStartResult $result): ?NextStepOption
{
$nextSteps = is_array($result->run->context['next_steps'] ?? null)
? $result->run->context['next_steps']
: [];
$storedNextStep = NextStepOption::collect($nextSteps)[0] ?? null;
if ($storedNextStep instanceof NextStepOption) {
return $storedNextStep;
}
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
return $reasonEnvelope?->firstNextStep();
}
}

View File

@ -21,6 +21,7 @@ public function __construct(
public ?string $operatorLabel,
public ?string $shortExplanation,
public ?string $diagnosticCode,
public ?string $actionability,
public string $trustImpact,
public ?string $absencePattern,
public array $nextSteps = [],
@ -43,6 +44,7 @@ public static function fromReasonResolutionEnvelope(
operatorLabel: $reason->operatorLabel,
shortExplanation: $reason->shortExplanation,
diagnosticCode: $reason->diagnosticCode(),
actionability: $reason->actionability,
trustImpact: $reason->trustImpact,
absencePattern: $reason->absencePattern,
nextSteps: array_values(array_map(
@ -79,7 +81,8 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing',
actionability: $this->actionability
?? ($this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing'),
nextSteps: array_map(
static fn (string $label): NextStepOption => NextStepOption::instruction($label),
$this->nextSteps,
@ -98,6 +101,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
* operatorLabel: ?string,
* shortExplanation: ?string,
* diagnosticCode: ?string,
* actionability: ?string,
* trustImpact: string,
* absencePattern: ?string,
* nextSteps: array<int, string>,
@ -114,6 +118,7 @@ public function toArray(): array
'operatorLabel' => $this->operatorLabel,
'shortExplanation' => $this->shortExplanation,
'diagnosticCode' => $this->diagnosticCode,
'actionability' => $this->actionability,
'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps,

View File

@ -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,

View File

@ -11,44 +11,6 @@
<div
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
>
<x-filament::section heading="Monitoring detail" class="mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
</div>
</div>
</x-filament::section>
@if ($contextBanner !== null)
@php
$bannerClasses = match ($contextBanner['tone']) {
@ -117,5 +79,43 @@
@endif
{{ $this->infolist }}
<x-filament::section heading="Monitoring detail" class="mt-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
</div>
</div>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@ -123,11 +123,11 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant);
$basisRunUrl = OperationRunLinks::view($run, $tenant);
$inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$inventoryItemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
$searchPage = visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant));
$searchPage
->waitForText('Inventory Items')

View File

@ -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(),
],

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\TenantReviewResource;
@ -15,6 +16,8 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('returns 404 for non-members on the baseline compare explanation surface', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner');
@ -99,3 +102,65 @@
->get(ReviewRegister::getUrl(panel: 'admin'))
->assertNotFound();
});
it('renders governance summary facts for entitled viewers on the canonical run detail surface', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'ambiguous_subjects',
'evidence_gaps' => [
'count' => 2,
],
],
],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Artifact impact')
->assertSee('Dominant cause')
->assertSee('Ambiguous matches');
});
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => 'active',
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => 'tenant.review_pack.generate',
'status' => 'completed',
'outcome' => 'succeeded',
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -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();

View File

@ -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' => [

View File

@ -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',

View File

@ -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();

View File

@ -79,6 +79,24 @@ protected function makePartialArtifactTruthEvidenceSnapshot(
);
}
protected function makeMissingArtifactTruthEvidenceSnapshot(
Tenant $tenant,
array $snapshotOverrides = [],
array $summaryOverrides = [],
): EvidenceSnapshot {
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
return $this->restateArtifactTruthEvidenceSnapshot(
$snapshot,
EvidenceCompletenessState::Missing,
array_replace([
'dimension_count' => 0,
'missing_dimensions' => 1,
'stale_dimensions' => 0,
], $summaryOverrides),
);
}
protected function makeArtifactTruthReview(
Tenant $tenant,
User $user,
@ -115,6 +133,32 @@ protected function makeArtifactTruthReview(
return TenantReview::query()->create(array_replace($defaults, $reviewOverrides));
}
protected function makePartialArtifactTruthReview(
Tenant $tenant,
User $user,
?EvidenceSnapshot $snapshot = null,
array $reviewOverrides = [],
array $summaryOverrides = [],
): TenantReview {
return $this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: array_replace([
'status' => TenantReviewStatus::Ready->value,
'completeness_state' => TenantReviewCompletenessState::Partial->value,
], $reviewOverrides),
summaryOverrides: array_replace_recursive([
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
], $summaryOverrides),
);
}
protected function makeBlockedArtifactTruthReview(
Tenant $tenant,
User $user,

View File

@ -0,0 +1,77 @@
<?php
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Jobs\EntraGroupSyncJob;
use App\Jobs\SyncRoleDefinitionsJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('starts directory group sync with explicit provider connection context', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class)
->callAction('sync_groups');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'entra_group_sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($run): bool {
return $job->providerConnectionId === ($run?->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($run);
});
});
it('blocks role definitions sync before queue when no provider connection is available', function (): void {
Bus::fake();
$tenant = Tenant::factory()->create([
'app_client_id' => 'client-123',
'app_client_secret' => 'secret',
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$result = app(RoleDefinitionsSyncService::class)->startManualSync($tenant, $user);
expect($result->status)->toBe('blocked');
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Bus::assertNotDispatched(SyncRoleDefinitionsJob::class);
});

View File

@ -38,11 +38,13 @@
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all');
expect($opRun?->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->selectionKey === 'groups-v1:all'
&& $job->runId === null
&& $job->providerConnectionId === ($opRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});

View File

@ -1,8 +1,8 @@
<?php
use App\Jobs\EntraGroupSyncJob;
use App\Models\OperationRun;
use App\Services\Directory\EntraGroupSyncService;
use App\Services\Providers\ProviderOperationStartResult;
use Illuminate\Support\Facades\Queue;
it('starts a manual group sync by creating a run and dispatching a job', function () {
@ -12,14 +12,19 @@
$service = app(EntraGroupSyncService::class);
$run = $service->startManualSync($tenant, $user);
$result = $service->startManualSync($tenant, $user);
$run = $result->run;
expect($run)->toBeInstanceOf(OperationRun::class)
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class)
->and($result->status)->toBe('started');
expect($run)
->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe('entra_group_sync')
->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all');
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
->and($run->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class);
});

View File

@ -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();

View File

@ -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(),
],

View File

@ -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();
});

View File

@ -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(),
],

View File

@ -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(),
],

View File

@ -66,8 +66,8 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
$basisRun = seedInventoryCoverageBasis($tenant);
$itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$itemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant);
$this->actingAs($user)
->get($itemsUrl)
@ -102,7 +102,7 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant))
->get(InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee('No current coverage basis')
->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.')

View File

@ -77,7 +77,7 @@ function visibleLivewireText(Testable $component): string
->assertSee('Outcome')
->assertSee('Artifact truth')
->assertSee('Execution failed')
->assertSee($explanation?->headline ?? '')
->assertSee('The baseline capture finished without a usable snapshot.')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Artifact not usable')
@ -136,11 +136,10 @@ function visibleLivewireText(Testable $component): string
->assertSee('Result trust')
->assertSee('Primary next step')
->assertSee('Artifact truth details')
->assertSee($explanation?->headline ?? '')
->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '')
->assertSee('The run completed, but normal output was intentionally suppressed.')
->assertSee('Resume or rerun evidence capture before relying on this compare result.')
->assertDontSee('Artifact next step');
@ -206,7 +205,7 @@ function visibleLivewireText(Testable $component): string
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '')
->assertSee('The compare finished, but a compare strategy failure kept the result incomplete.')
->assertSee($explanation?->nextActionText ?? '')
->assertSee('Compare strategy')
->assertSee('Intune Policy')
@ -314,7 +313,7 @@ function visibleLivewireText(Testable $component): string
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '')
->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertDontSee('No confirmed drift in the latest baseline compare.');

View File

@ -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' => [

View File

@ -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;
}

View File

@ -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);
});

View File

@ -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 {

View File

@ -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');

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
function providerDispatchGateSlice(string $source, string $startAnchor, ?string $endAnchor = null): string
{
$start = strpos($source, $startAnchor);
expect($start)->not->toBeFalse();
$start = is_int($start) ? $start : 0;
if ($endAnchor === null) {
return substr($source, $start);
}
$end = strpos($source, $endAnchor, $start + strlen($startAnchor));
expect($end)->not->toBeFalse();
$end = is_int($end) ? $end : strlen($source);
return substr($source, $start, $end - $start);
}
it('keeps first-slice route-bounded provider starts on canonical gate-owned entry points', function (): void {
$root = SourceFileScanner::projectRoot();
$checks = [
[
'file' => $root.'/app/Services/Verification/StartVerification.php',
'start' => 'public function providerConnectionCheckForTenant(',
'end' => 'public function providerConnectionCheckUsingConnection(',
'required' => [
'return $this->providers->start(',
"operationType: 'provider.connection.check'",
"'required_capability' => Capabilities::PROVIDER_RUN",
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Verification/StartVerification.php',
'start' => 'public function providerConnectionCheckUsingConnection(',
'end' => 'private function dispatchConnectionHealthCheck(',
'required' => [
'$result = $this->providers->start(',
"operationType: 'provider.connection.check'",
'ProviderVerificationStatus::Pending',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Directory/EntraGroupSyncService.php',
'start' => 'public function startManualSync(',
'end' => 'public function sync(',
'required' => [
'return $this->providerStarts->start(',
"operationType: 'entra_group_sync'",
'EntraGroupSyncJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Directory/RoleDefinitionsSyncService.php',
'start' => 'public function startManualSync(',
'end' => 'public function sync(',
'required' => [
'return $this->providerStarts->start(',
"operationType: 'directory_role_definitions.sync'",
'SyncRoleDefinitionsJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/RestoreRunResource.php',
'start' => 'private static function startQueuedRestoreExecution(',
'end' => 'private static function detailPreviewState(',
'required' => [
'app(ProviderOperationStartGate::class)->start(',
"operationType: 'restore.execute'",
'ExecuteRestoreRunJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
'start' => "Action::make('sync_groups')",
'end' => '->requireCapability(Capabilities::TENANT_SYNC)',
'required' => [
'$syncService->startManualSync($tenant, $user)',
'ProviderOperationStartResultPresenter::class',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/TenantResource.php',
'start' => 'public static function syncRoleDefinitionsAction(): Actions\\Action',
'end' => null,
'required' => [
'$result = $syncService->startManualSync($record, $user);',
'ProviderOperationStartResultPresenter::class',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
'start' => 'private static function handleCheckConnectionAction(',
'end' => 'private static function handleProviderOperationAction(',
'required' => [
'$verification->providerConnectionCheck(',
'ProviderOperationStartResultPresenter::class',
'OperationRunLinks::view($result->run, $tenant)',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
'start' => 'private static function handleProviderOperationAction(',
'end' => 'public static function getEloquentQuery(): Builder',
'required' => [
'$result = $gate->start(',
'ProviderOperationStartResultPresenter::class',
'OperationRunLinks::view($result->run, $tenant)',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
'start' => 'public function startBootstrap(array $operationTypes): void',
'end' => 'private function dispatchBootstrapJob(',
'required' => [
'app(ProviderOperationStartGate::class)->start(',
'ProviderOperationStartResultPresenter::class',
"'bootstrap_operation_types'",
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
];
foreach ($checks as $check) {
$source = SourceFileScanner::read($check['file']);
$slice = providerDispatchGateSlice($source, $check['start'], $check['end']);
foreach ($check['required'] as $needle) {
expect($slice)->toContain($needle);
}
foreach ($check['forbidden'] as $needle) {
expect($slice)->not->toContain($needle);
}
}
})->group('surface-guard');

View File

@ -136,6 +136,7 @@
->and($families->has('policy-resource-admin-search-parity'))->toBeTrue()
->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue()
->and($families->has('workspace-settings-slice-management'))->toBeTrue()
->and($families->has('provider-dispatch-gate-coverage'))->toBeTrue()
->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue()
->and($families->has('browser-smoke'))->toBeTrue();
@ -159,7 +160,7 @@
expect($familyBudgets)->not->toBeEmpty()
->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds'])
->and(collect($familyBudgets)->pluck('familyId')->all())
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'provider-dispatch-gate-coverage', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
});
it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void {

View File

@ -5,11 +5,24 @@
use App\Models\InventoryLink;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Str;
function inventoryItemAdminSession(Tenant $tenant): array
{
return [
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
];
}
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
@ -19,7 +32,7 @@
// Zero state
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)->assertOk()->assertSee('No dependencies found');
$this->withSession($session)->get($url)->assertOk()->assertSee('No dependencies found');
// Create a missing edge and assert badge appears
InventoryLink::factory()->create([
@ -35,7 +48,7 @@
],
]);
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertSee('Missing')
->assertSee('Last known: Ghost Target');
@ -44,6 +57,8 @@
it('renders native dependency controls in place instead of a GET apply workflow', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
@ -82,7 +97,7 @@
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertSee('Direction')
->assertSee('Inbound')
@ -95,6 +110,8 @@
it('ignores legacy relationship query state while preserving visible target safety', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
@ -126,7 +143,7 @@
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin')
.'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by';
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertSee('Scoped Target')
->assertSee('Assigned Target');
@ -135,6 +152,8 @@
it('does not show edges from other tenants (tenant isolation)', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
@ -156,7 +175,7 @@
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertDontSee('Other Tenant Edge');
});
@ -164,6 +183,8 @@
it('shows masked identifier when last known name is missing', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
@ -185,7 +206,7 @@
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertSee('Group (external): 123456…');
});
@ -193,6 +214,8 @@
it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */
$item = InventoryItem::factory()->create([
@ -254,7 +277,7 @@
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertSee('Scope Tag: Finance (6…)')
->assertSee('Assignment Filter: VIP Devices (62fb77…)')
@ -264,6 +287,8 @@
it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () {
[$user, $tenant] = createUserWithTenant();
$this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
$graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldNotReceive('listPolicies');
@ -301,7 +326,7 @@
]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)
$this->withSession($session)->get($url)
->assertOk()
->assertSee('Scope Tag: Finance');
});

View File

@ -992,7 +992,7 @@
expect($session->completed_at)->not->toBeNull();
});
it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void {
it('starts one selected bootstrap action at a time and persists the remaining selections', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
@ -1048,17 +1048,105 @@
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->whereIn('type', ['inventory_sync', 'compliance.snapshot'])
->count())->toBe(2);
->where('type', 'inventory_sync')
->count())->toBe(1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray();
expect($runs['inventory_sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
});
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-next-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->firstOrFail();
$inventoryRun->forceFill([
'status' => 'completed',
'outcome' => 'succeeded',
])->save();
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(1);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs['inventory_sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
});

View File

@ -104,6 +104,6 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.evidence.overview'))
->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA))
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied));
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA, panel: 'tenant'))
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied, panel: 'tenant'));
});

View File

@ -43,7 +43,7 @@
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Artifact truth')
->assertSee($explanation?->headline ?? '')
->assertSee('The snapshot finished processing, but its evidence basis is incomplete.')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Partially complete')

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
use Tests\TestCase;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
function governanceVisibleText(Testable $component): string
{
$html = $component->html();
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
}
function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, OperationRun $run): Testable
{
Filament::setTenant(null, true);
$testCase->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
return Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('renders a summary-first hierarchy for zero-output baseline compare runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'coverage_unproven',
'coverage' => [
'proof' => false,
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 1,
],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Decision')
->assertSee('Artifact impact')
->assertSee('Dominant cause')
->assertSee('Primary next step')
->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee('Artifact truth details')
->assertSee('Monitoring detail');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'))
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Monitoring detail'))
->and($pageText)->toContain('no decision-grade result is available yet');
});
it('keeps blocked baseline capture summaries ahead of diagnostics without adding new run-detail actions', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => 'missing_capability',
'baseline_capture' => [
'subjects_total' => 0,
'gaps' => [
'count' => 0,
],
],
],
'failure_summary' => [[
'reason_code' => 'missing_capability',
'message' => 'A required capability is missing for this run.',
]],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertActionVisible('operate_hub_back_to_operations')
->assertActionVisible('refresh')
->assertSee('Blocked by prerequisite')
->assertSee('No baseline was captured')
->assertSee('Artifact impact')
->assertSee('Dominant cause');
$pageText = governanceVisibleText($component);
expect(mb_substr_count($pageText, 'No baseline was captured'))->toBe(1)
->and(mb_strpos($pageText, 'No baseline was captured'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows processing outcome separately from artifact impact for stale evidence snapshot runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
$this->makeStaleArtifactTruthEvidenceSnapshot(
tenant: $tenant,
snapshotOverrides: [
'operation_run_id' => (int) $run->getKey(),
],
);
governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Outcome')
->assertSee('Artifact impact')
->assertSee('Completed successfully')
->assertSee('The snapshot finished processing, but its evidence basis is already stale.')
->assertSee('Result trust');
});
it('preserves a dominant cause plus secondary causes for degraded review composition runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant);
$this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: [
'operation_run_id' => (int) $run->getKey(),
'completeness_state' => 'partial',
],
summaryOverrides: [
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
],
);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Dominant cause')
->assertSee('Missing sections')
->assertSee('Secondary causes')
->assertSee('Stale evidence basis');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes'))
->and($pageText)->toContain('stale evidence');
});

View File

@ -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(),
],

View File

@ -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(),
],

View File

@ -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,
],

View File

@ -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' => [

View File

@ -397,8 +397,8 @@
->assertSuccessful()
->assertSee('Complete onboarding')
->assertDontSee('Activate tenant')
->assertDontSee('Restore')
->assertDontSee('Archive')
->assertDontSeeText('Restore tenant')
->assertDontSeeText('Archive tenant')
->assertSee('After completion');
});

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Notifications\OperationRunCompleted;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function makeProviderBlockedRun(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => 999999,
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
'blocked_by' => 'provider_preflight',
'target_scope' => [
'entra_tenant_id' => $tenant->tenant_id,
],
],
]);
return [$user, $tenant, $run];
}
it('reuses translated provider-backed blocker language on the canonical run detail page', function (): void {
[$user, , $run] = makeProviderBlockedRun();
$this->actingAs($user);
$component = Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
$banner = $component->instance()->blockedExecutionBanner();
expect($banner)->not->toBeNull();
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
expect($banner['body'] ?? null)->toContain('Provider connection required');
expect($banner['body'] ?? null)->toContain('usable provider connection');
});
it('keeps terminal notification reason translation aligned with the canonical provider-backed run detail', function (): void {
[$user, , $run] = makeProviderBlockedRun();
$this->actingAs($user);
$banner = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->instance()
->blockedExecutionBanner();
$payload = (new OperationRunCompleted($run))->toDatabase($user);
expect($payload['title'] ?? null)->toBe('Inventory sync blocked by prerequisite');
expect($payload['body'] ?? null)->toContain('Provider connection required');
expect($payload['body'] ?? null)->toContain('usable provider connection');
expect($payload['reason_translation']['operator_label'] ?? null)->toContain('Provider connection required');
expect($payload['reason_translation']['short_explanation'] ?? null)->toContain('usable provider connection');
expect($payload['diagnostic_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
expect($banner['body'] ?? '')->toContain('Provider connection required');
});
it('keeps the same blocked provider-backed vocabulary for system-initiated runs on canonical detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'Scheduled automation',
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => 999999,
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
'blocked_by' => 'provider_preflight',
'target_scope' => [
'entra_tenant_id' => $tenant->tenant_id,
],
],
]);
$banner = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->instance()
->blockedExecutionBanner();
expect($banner)->not->toBeNull();
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
expect($banner['body'] ?? null)->toContain('Provider connection required');
expect($banner['body'] ?? null)->toContain('usable provider connection');
});

View File

@ -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,
]);

View File

@ -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]],

View File

@ -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');

View File

@ -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.');

View File

@ -0,0 +1,90 @@
<?php
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationRunLinks;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('returns scope-busy semantics when a different provider operation is already active for the same connection', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'consent_status' => 'granted',
]);
$component = Livewire::test(ListProviderConnections::class);
$component->callTableAction('inventory_sync', $connection);
$component->callTableAction('compliance_snapshot', $connection);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->first();
expect($inventoryRun)->not->toBeNull();
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0);
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($inventoryRun, $tenant));
});
it('blocks provider connection checks with shared guidance and does not enqueue work', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'consent_status' => 'granted',
'is_default' => true,
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->outcome)->toBe('blocked');
expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
Queue::assertNothingPushed();
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
});

View File

@ -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();
});

View File

@ -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();
});
});

View File

@ -0,0 +1,163 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RestoreRunStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
function seedRestoreStartContext(bool $withProviderConnection = true): array
{
$tenant = Tenant::create([
'tenant_id' => fake()->uuid(),
'name' => 'Restore Tenant',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
if ($withProviderConnection) {
ensureDefaultProviderConnection($tenant, 'microsoft');
}
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => fake()->uuid(),
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => [
'displayName' => 'Backup Policy',
],
]);
$user = User::factory()->create([
'email' => 'restore@example.com',
'name' => 'Restore Operator',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
return [$tenant, $backupSet, $backupItem, $user];
}
it('starts restore execution with explicit provider connection context', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext();
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Restore Tenant',
])
->call('create')
->assertHasNoFormErrors();
$restoreRun = RestoreRun::query()->latest('id')->first();
$operationRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($restoreRun)->not->toBeNull();
expect($restoreRun?->status)->toBe(RestoreRunStatus::Queued->value);
expect($operationRun)->not->toBeNull();
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($restoreRun, $operationRun): bool {
return $job->restoreRunId === (int) $restoreRun?->getKey()
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($operationRun);
});
});
it('blocks restore reruns before queue when no provider connection is available', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext(withProviderConnection: false);
$this->actingAs($user);
$run = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'failed',
'is_dry_run' => false,
'requested_items' => [$backupItem->id],
'group_mapping' => [],
]);
Livewire::test(ListRestoreRuns::class)
->callTableAction('rerun', $run);
expect(RestoreRun::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
$operationRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun?->outcome)->toBe('blocked');
expect($operationRun?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Bus::assertNotDispatched(ExecuteRestoreRunJob::class);
});

View File

@ -189,10 +189,12 @@
expect($operationRun)->not->toBeNull();
expect($operationRun?->status)->toBe('queued');
expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey());
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0));
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool {
return $job->restoreRunId === (int) $run->getKey()
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun instanceof OperationRun
&& $job->operationRun->getKey() === $operationRun?->getKey();
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartResult;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -20,19 +21,31 @@
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
fixtureProfile: 'credential-enabled',
);
$service = app(RoleDefinitionsSyncService::class);
$run = $service->startManualSync($tenant, $user);
$result = $service->startManualSync($tenant, $user);
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class);
expect($result->status)->toBe('started');
$run = $result->run;
expect($run->type)->toBe('directory_role_definitions.sync');
expect($run->context['provider_connection_id'] ?? null)->toBeInt();
$url = OperationRunLinks::tenantlessView($run);
expect($url)->toContain('/admin/operations/');
Bus::assertDispatched(
App\Jobs\SyncRoleDefinitionsJob::class,
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run)
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey()
&& $job->providerConnectionId === ($run->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($run)
);
});

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