feat: harden canonical run viewer and onboarding draft state (#173)

## Summary
- harden the canonical operation run viewer so mismatched, missing, archived, onboarding, and selector-excluded tenant context no longer invalidates authorized canonical run viewing
- extend canonical route, header-context, deep-link, and presentation coverage for Spec 144 and add the full spec artifact set under `specs/144-canonical-operation-viewer-context-decoupling/`
- harden onboarding draft provider-connection resume logic so stale persisted provider connections fall back to the connect-provider step instead of resuming invalid state
- add architecture-audit follow-up candidate material and prompt assets for the next governance hardening wave

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Monitoring/HeaderContextBarTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php`

## Notes
- branch: `144-canonical-operation-viewer-context-decoupling`
- base: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #173
This commit is contained in:
ahmido 2026-03-15 18:32:04 +00:00
parent 641bb4afde
commit b0a724acef
38 changed files with 2202 additions and 99 deletions

View File

@ -0,0 +1,104 @@
---
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
---
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
Your task is to produce spec candidates, not implementation code.
Before writing anything, read and use these repository files as binding context:
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
- `docs/audits/2026-03-15-audit-spec-candidates.md`
- `specs/110-ops-ux-enforcement/spec.md`
- `specs/111-findings-workflow-sla/spec.md`
- `specs/134-audit-log-foundation/spec.md`
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
## Goal
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
The four candidate themes are:
1. queued execution reauthorization and scope continuity
2. tenant-owned query canon and wrong-tenant guards
3. findings workflow enforcement and audit backstop
4. Livewire context locking and trusted-state reduction
## Numbering rule
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
## Output requirements
Create exactly four spec candidates, one per problem class.
For each candidate provide:
1. Candidate label or confirmed spec number
2. Working title
3. Status: `Proposed`
4. Summary
5. Why this is needed now
6. Boundary to existing specs
7. Problem statement
8. Goals
9. Non-goals
10. Scope
11. Target model
12. Key requirements
13. Risks if not implemented
14. Dependencies and sequencing notes
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
17. Suggested slug
At the end provide:
A. Recommended implementation order
B. Which candidates can run in parallel
C. Which candidate should start first and why
D. A numbering strategy recommendation if active spec numbers are not yet known
## Writing rules
- Write in English.
- Use formal enterprise spec language.
- Be concrete and opinionated.
- Focus on structural integrity, not patch-level fixes.
- Treat the audit constitution as binding.
- Explicitly say when UI-only authorization is insufficient.
- Explicitly say when Livewire public state must be treated as untrusted input.
- Explicitly say when negative-path regression tests are required.
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
- Do not duplicate adjacent specs; state the boundary clearly.
- Do not collapse all four themes into one umbrella spec.
## Candidate-specific direction
### Candidate A — queued execution reauthorization and scope continuity
- Treat this as an execution trust problem, not a simple `authorize()` omission.
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
- Define what happens when authorization or tenant operability changes between dispatch and execution.
### Candidate B — tenant-owned query canon and wrong-tenant guards
- Treat this as canonical data-access hardening.
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
- Focus on ownership enforcement, not generic repository-pattern advice.
### Candidate C — findings workflow enforcement and audit backstop
- Treat this as a workflow-truth problem.
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
- Make clear how this extends but does not duplicate Spec 111.
### Candidate D — Livewire context locking and trusted-state reduction
- Treat this as a UI/server trust-boundary hardening problem.
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
- Make clear how this complements but does not duplicate Spec 138.

View File

@ -77,6 +77,8 @@ ## Active Technologies
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -96,8 +98,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 144-canonical-operation-viewer-context-decoupling: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks`
- 143-tenant-lifecycle-operability-context-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4
- 142-rbac-role-definition-diff-ux-upgrade: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141
- 141-shared-diff-presentation-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,105 @@
---
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
agent: speckit.specify
---
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
Your task is to produce spec candidates, not implementation code.
Before writing anything, read and use these repository files as binding context:
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
- `docs/audits/2026-03-15-audit-spec-candidates.md`
- `specs/110-ops-ux-enforcement/spec.md`
- `specs/111-findings-workflow-sla/spec.md`
- `specs/134-audit-log-foundation/spec.md`
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
## Goal
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
The four candidate themes are:
1. queued execution reauthorization and scope continuity
2. tenant-owned query canon and wrong-tenant guards
3. findings workflow enforcement and audit backstop
4. Livewire context locking and trusted-state reduction
## Numbering rule
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
## Output requirements
Create exactly four spec candidates, one per problem class.
For each candidate provide:
1. Candidate label or confirmed spec number
2. Working title
3. Status: `Proposed`
4. Summary
5. Why this is needed now
6. Boundary to existing specs
7. Problem statement
8. Goals
9. Non-goals
10. Scope
11. Target model
12. Key requirements
13. Risks if not implemented
14. Dependencies and sequencing notes
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
17. Suggested slug
At the end provide:
A. Recommended implementation order
B. Which candidates can run in parallel
C. Which candidate should start first and why
D. A numbering strategy recommendation if active spec numbers are not yet known
## Writing rules
- Write in English.
- Use formal enterprise spec language.
- Be concrete and opinionated.
- Focus on structural integrity, not patch-level fixes.
- Treat the audit constitution as binding.
- Explicitly say when UI-only authorization is insufficient.
- Explicitly say when Livewire public state must be treated as untrusted input.
- Explicitly say when negative-path regression tests are required.
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
- Do not duplicate adjacent specs; state the boundary clearly.
- Do not collapse all four themes into one umbrella spec.
## Candidate-specific direction
### Candidate A — queued execution reauthorization and scope continuity
- Treat this as an execution trust problem, not a simple `authorize()` omission.
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
- Define what happens when authorization or tenant operability changes between dispatch and execution.
### Candidate B — tenant-owned query canon and wrong-tenant guards
- Treat this as canonical data-access hardening.
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
- Focus on ownership enforcement, not generic repository-pattern advice.
### Candidate C — findings workflow enforcement and audit backstop
- Treat this as a workflow-truth problem.
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
- Make clear how this extends but does not duplicate Spec 111.
### Candidate D — Livewire context locking and trusted-state reduction
- Treat this as a UI/server trust-boundary hardening problem.
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
- Make clear how this complements but does not duplicate Spec 138.

View File

@ -11,6 +11,7 @@
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
@ -163,6 +164,61 @@ public function redactionIntegrityNote(): ?string
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function canonicalContextBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$runTenant = $this->run->tenant;
if (! $runTenant instanceof Tenant) {
return [
'tone' => 'slate',
'title' => 'Workspace-level run',
'body' => $activeTenant instanceof Tenant
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
: 'This canonical workspace view is not tied to any tenant.',
];
}
$messages = ['Run tenant: '.$runTenant->name.'.'];
$tone = 'sky';
$title = null;
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
$title = 'Current tenant context differs from this run';
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
}
$tenantOperability = app(TenantOperabilityService::class)->decisionFor($runTenant);
if (! $tenantOperability->canSelectAsContext) {
$title ??= 'Run tenant is not available in the current tenant selector';
$tone = 'amber';
$messages[] = 'This tenant is currently '.Str::lower($tenantOperability->lifecycle->label()).' and may not appear in the tenant selector.';
$messages[] = 'Some tenant follow-up actions may be unavailable from this canonical workspace view.';
} elseif (! $activeTenant instanceof Tenant) {
$title ??= 'Canonical workspace view';
$messages[] = 'No tenant context is currently selected.';
}
if ($title === null) {
return null;
}
return [
'tone' => $tone,
'title' => $title,
'body' => implode(' ', $messages),
];
}
public function pollInterval(): ?string
{
if (! isset($this->run)) {

View File

@ -676,9 +676,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
}
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
$this->selectedProviderConnectionId = is_int($providerConnectionId)
? $providerConnectionId
: ($tenant instanceof Tenant ? $this->resolveDefaultProviderConnectionId($tenant) : null);
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
@ -1106,11 +1104,7 @@ private function refreshOnboardingDraftFromBackend(): void
}
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
$providerConnectionId = is_int($providerConnectionId)
? $providerConnectionId
: (is_numeric($providerConnectionId) ? (int) $providerConnectionId : null);
$this->selectedProviderConnectionId = $providerConnectionId;
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
$this->initializeWizardData();
}
@ -1257,8 +1251,8 @@ private function initializeWizardData(): void
}
}
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
if (is_int($providerConnectionId)) {
$providerConnectionId = $this->resolvePersistedProviderConnectionId($this->onboardingSession->state['provider_connection_id'] ?? null);
if ($providerConnectionId !== null) {
$this->data['provider_connection_id'] = $providerConnectionId;
$this->selectedProviderConnectionId = $providerConnectionId;
}
@ -1269,8 +1263,12 @@ private function initializeWizardData(): void
}
}
if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) {
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($this->selectedProviderConnectionId);
if ($this->selectedProviderConnectionId !== null) {
$this->data['provider_connection_id'] = $this->selectedProviderConnectionId;
} else {
$this->data['provider_connection_id'] = null;
}
}
@ -3260,9 +3258,7 @@ private function verificationRunMatchesSelectedConnection(OperationRun $run): bo
if ($selectedProviderConnectionId === null && $this->onboardingSession instanceof TenantOnboardingSession) {
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
$selectedProviderConnectionId = is_int($candidate)
? $candidate
: (is_numeric($candidate) ? (int) $candidate : null);
$selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($candidate);
}
if ($selectedProviderConnectionId === null) {
@ -3708,4 +3704,33 @@ private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderCon
->whereKey($providerConnectionId)
->first();
}
private function resolvePersistedProviderConnectionId(mixed $providerConnectionId): ?int
{
$providerConnectionId = is_int($providerConnectionId)
? $providerConnectionId
: (is_numeric($providerConnectionId) ? (int) $providerConnectionId : null);
if ($providerConnectionId === null) {
return null;
}
$tenantId = $this->managedTenant?->getKey();
if (! is_int($tenantId) && $this->onboardingSession instanceof TenantOnboardingSession) {
$tenantId = is_numeric($this->onboardingSession->tenant_id) ? (int) $this->onboardingSession->tenant_id : null;
}
if (! is_int($tenantId)) {
return null;
}
$exists = ProviderConnection::query()
->whereKey($providerConnectionId)
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', $tenantId)
->exists();
return $exists ? $providerConnectionId : null;
}
}

View File

@ -49,7 +49,7 @@ protected static function booted(): void
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
return $this->belongsTo(Tenant::class)->withTrashed();
}
public function workspace(): BelongsTo

View File

@ -82,7 +82,7 @@ public function view(User $user, OperationRun $run): Response|bool
}
if ($tenantId > 0) {
$tenant = Tenant::query()->whereKey($tenantId)->first();
$tenant = Tenant::query()->withTrashed()->whereKey($tenantId)->first();
if (! $tenant instanceof Tenant) {
return Response::denyAsNotFound();

View File

@ -83,20 +83,26 @@ public function resumableDraftsFor(User $user, Workspace $workspace): Collection
->orderByDesc('updated_at')
->get();
return $drafts
->map(function (TenantOnboardingSession $draft) use ($user): ?TenantOnboardingSession {
try {
Gate::forUser($user)->authorize('view', $draft);
} catch (AuthorizationException) {
return null;
}
$resolvedDrafts = [];
return $this->lifecycleService
->syncPersistedLifecycle($draft)
->loadMissing(['tenant', 'startedByUser', 'updatedByUser']);
})
->filter(fn (?TenantOnboardingSession $draft): bool => $draft instanceof TenantOnboardingSession
&& $this->lifecycleService->canResumeDraft($draft))
->values();
foreach ($drafts as $draft) {
try {
Gate::forUser($user)->authorize('view', $draft);
} catch (AuthorizationException) {
continue;
}
$resolvedDraft = $this->lifecycleService
->syncPersistedLifecycle($draft)
->loadMissing(['tenant', 'startedByUser', 'updatedByUser']);
if (! $this->lifecycleService->canResumeDraft($resolvedDraft)) {
continue;
}
$resolvedDrafts[] = $resolvedDraft;
}
return new Collection($resolvedDrafts);
}
}

View File

@ -6,6 +6,7 @@
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Services\Tenants\TenantOperabilityService;
@ -457,7 +458,19 @@ private function selectedProviderConnectionId(TenantOnboardingSession $draft): ?
{
$state = is_array($draft->state) ? $draft->state : [];
return $this->normalizeInteger($state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null);
$providerConnectionId = $this->normalizeInteger($state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null);
if ($providerConnectionId === null || $draft->tenant_id === null) {
return null;
}
$exists = ProviderConnection::query()
->whereKey($providerConnectionId)
->where('workspace_id', (int) $draft->workspace_id)
->where('tenant_id', (int) $draft->tenant_id)
->exists();
return $exists ? $providerConnectionId : null;
}
private function connectionRecentlyUpdated(TenantOnboardingSession $draft): bool

View File

@ -13,6 +13,7 @@
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Filament\Facades\Filament;
@ -51,7 +52,7 @@ public function handle(Request $request, Closure $next): Response
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
if ($this->isCanonicalWorkspaceRecordViewerPath($refererPath)) {
$this->configureNavigationForRequest($panel);
return $next($request);
@ -64,7 +65,7 @@ public function handle(Request $request, Closure $next): Response
}
}
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
if ($this->isCanonicalWorkspaceRecordViewerPath($path)) {
$this->configureNavigationForRequest($panel);
return $next($request);
@ -264,6 +265,11 @@ private function isWorkspaceScopedPageWithTenant(string $path): bool
return preg_match('#^/admin/tenants/[^/]+/required-permissions$#', $path) === 1;
}
private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
{
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
}
private function adminPathRequiresTenantSelection(string $path): bool
{
if (! str_starts_with($path, '/admin/')) {

View File

@ -86,7 +86,7 @@ public function activeEntitledTenant(?Request $request = null): ?Tenant
private function resolveActiveTenant(?Request $request = null): ?Tenant
{
$pageCategory = TenantPageCategory::fromRequest($request);
$pageCategory = $this->pageCategory($request);
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
if ($request?->route()?->hasParameter('tenant')) {
@ -125,7 +125,7 @@ private function resolveActiveTenant(?Request $request = null): ?Tenant
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
{
$route = $request?->route();
$pageCategory ??= TenantPageCategory::fromRequest($request);
$pageCategory ??= $this->pageCategory($request);
if ($route?->hasParameter('tenant')) {
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
@ -204,4 +204,9 @@ private function isEntitled(Tenant $tenant, ?Request $request = null, ?TenantPag
default => $decision->canSelectAsContext,
};
}
private function pageCategory(?Request $request = null): TenantPageCategory
{
return TenantPageCategory::fromRequest($request);
}
}

View File

@ -9,18 +9,30 @@ ## Goal
These are intentionally **problem-cluster specs**, not bug tickets. Each candidate groups multiple symptoms under one architectural diagnosis so the repo does not fragment into dozens of local fixes.
## Numbering Strategy
Do not treat the candidate ordering in this document as reserved spec numbering.
- Use working labels first: Candidate A through Candidate D.
- Assign a formal `specs/<NNN>-<slug>/` number only after checking the active repository queue and any already-created feature branches.
- If multiple candidates are promoted, number them in the order they are actually accepted into the active spec stream, not the order shown below.
## Recommended Order
1. Spec 144: queued execution reauthorization and scope continuity
2. Spec 145: tenant-owned query canon and wrong-tenant regression guards
3. Spec 146: findings workflow enforcement and audit backstop
4. Spec 147: Livewire context locking and trusted-state reduction
1. Candidate A: queued execution reauthorization and scope continuity
2. Candidate B: tenant-owned query canon and wrong-tenant regression guards
3. Candidate C: findings workflow enforcement and audit backstop
4. Candidate D: Livewire context locking and trusted-state reduction
## Candidate 144
## Candidate A
### Proposed slug
`144-queued-execution-reauthorization-scope-continuity`
`queued-execution-reauthorization-scope-continuity`
### Suggested provisional title
Queued Execution Reauthorization and Scope Continuity
### Architectural diagnosis
@ -71,11 +83,15 @@ ### Delivery recommendation
Dedicated spec required.
## Candidate 145
## Candidate B
### Proposed slug
`145-tenant-owned-query-canon-and-wrong-tenant-guards`
`tenant-owned-query-canon-and-wrong-tenant-guards`
### Suggested provisional title
Tenant-Owned Query Canon and Wrong-Tenant Guards
### Architectural diagnosis
@ -123,11 +139,15 @@ ### Delivery recommendation
Dedicated spec required.
## Candidate 146
## Candidate C
### Proposed slug
`146-findings-workflow-enforcement-and-audit-backstop`
`findings-workflow-enforcement-and-audit-backstop`
### Suggested provisional title
Findings Workflow Enforcement and Audit Backstop
### Architectural diagnosis
@ -176,11 +196,15 @@ ### Delivery recommendation
Dedicated spec required.
## Candidate 147
## Candidate D
### Proposed slug
`147-livewire-context-locking-and-trusted-state-reduction`
`livewire-context-locking-and-trusted-state-reduction`
### Suggested provisional title
Livewire Context Locking and Trusted-State Reduction
### Architectural diagnosis
@ -230,6 +254,15 @@ ### Delivery recommendation
Dedicated spec required.
## Promotion Rule
When a candidate is approved for actual specification work:
1. Re-check the current highest active spec folder and any already-created feature branches.
2. Allocate the next valid number at promotion time.
3. Copy the candidate title and slug into the new `specs/<NNN>-<slug>/spec.md` workflow.
4. Preserve this document as the pre-spec triage artifact instead of editing history to pretend numbering was fixed earlier.
## Deferred Candidate
### OperationRun result referential integrity

View File

@ -3,7 +3,45 @@ # Discoveries
> Things found during implementation that don't belong in the current spec.
> Review weekly. Promote to [spec-candidates.md](spec-candidates.md) or discard.
**Last reviewed**: 2026-03-08
Items that are already tracked in [spec-candidates.md](spec-candidates.md) or [roadmap.md](roadmap.md) should not remain here.
**Last reviewed**: 2026-03-15
---
## 2026-03-15 — Queued execution trust relies too much on dispatch-time authority
- **Source**: architecture audit
- **Observation**: Queued jobs still rely too heavily on the actor, tenant, and authorization state captured at dispatch time. Execution-time scope continuity and reauthorization are not yet hardened as a canonical backend contract.
- **Category**: hardening
- **Priority**: high
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate A: queued execution reauthorization and scope continuity.
---
## 2026-03-15 — Tenant-owned query canon remains too ad hoc
- **Source**: architecture audit
- **Observation**: Tenant isolation is broadly present, but many tenant-owned reads still depend on repeated local `tenant_id` filtering instead of a reusable canonical query path. This increases drift risk and weakens wrong-tenant regression discipline.
- **Category**: hardening
- **Priority**: high
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate B: tenant-owned query canon and wrong-tenant guards.
---
## 2026-03-15 — Findings lifecycle truth is stronger in docs than in enforcement
- **Source**: architecture audit
- **Observation**: Findings workflow semantics are well-defined at spec level, but architectural enforcement still depends too much on service-path discipline. Direct or bypassing status mutations remain too plausible.
- **Category**: hardening
- **Priority**: high
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate C: findings workflow enforcement and audit backstop.
---
## 2026-03-15 — Livewire trust-boundary hardening is still convention-driven
- **Source**: architecture audit
- **Observation**: Complex Livewire and Filament flows still expose too much ownership-relevant context in public component state. This is not a proven exploit in the repo today, but the hardening standard is not yet explicit or reusable.
- **Category**: hardening
- **Priority**: medium
- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate D: Livewire context locking and trusted-state reduction.
---
@ -52,24 +90,6 @@ ## 2026-03-08 — Drift engine hard-fail when no Inventory Sync exists
---
## 2026-03-08 — Inventory landing page may be redundant
- **Source**: Product review, dashboard analysis
- **Observation**: The Inventory nav section has a landing "Home" page that may not add value beyond what the Policies and Policy Versions pages provide directly.
- **Category**: UX polish
- **Priority**: low
- **Suggested follow-up**: Consider making Inventory a pure navigation group (no landing page) in a future IA cleanup.
---
## 2026-03-08 — Dashboard lacks enterprise-grade visual hierarchy
- **Source**: Product review 2026-03-08
- **Observation**: Stat widgets show raw numbers without trends. "Needs Attention" zone is visually equal to other content. Baseline Governance card is small and easy to miss. Operations table lacks duration/count columns.
- **Category**: UX polish
- **Priority**: medium
- **Suggested follow-up**: Promoted to spec-candidates.md as "Dashboard Polish (Enterprise-grade)".
---
## 2026-03-08 — Performance indexes for system console windowed queries
- **Source**: Spec 114 (System Console Control Tower)
- **Observation**: EXPLAIN baselines don't show pressure yet, but windowed queries on operation_runs could become slow at scale. Indexes were explicitly deferred.

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs.
**Last updated**: 2026-03-08
**Last updated**: 2026-03-15
---
@ -20,6 +20,14 @@ ## Release History
## Active / Near-term
### Governance & Architecture Hardening
Canonical run-view trust semantics, execution-time authorization continuity, tenant-owned query canon, findings workflow enforcement, Livewire trust-boundary reduction.
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands.
**Active specs**: 144
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
**Source**: architecture audit 2026-03-15, audit constitution, product spec-candidates
### UI & Product Maturity Polish
Empty state consistency, list-expand parity, workspace chooser refinement, navigation semantics.
Goal: Every surface feels intentional and guided for first-run evaluation.

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
**Last reviewed**: 2026-03-10
**Last reviewed**: 2026-03-15
---
@ -27,6 +27,55 @@ ## Qualified
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
### Governance Architecture Hardening Wave
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: The architecture audit surfaced four cross-cutting governance gaps that are too structural for isolated bugfixes: queued execution trust, tenant-owned query canon drift, findings workflow enforcement softness, and convention-driven Livewire trust boundaries.
- **Why it matters**: These are enterprise trust issues, not cosmetic cleanup. Left unresolved, they increase the probability of scope drift, authorization decay across async boundaries, workflow bypass, and mutable UI-state trust in a product that manages tenant-sensitive governance flows.
- **Proposed direction**: Treat the audit as a candidate wave, not as one umbrella mega-spec. Promote the four candidates individually when slots are available:
- Queued execution reauthorization and scope continuity
- Tenant-owned query canon and wrong-tenant guards
- Findings workflow enforcement and audit backstop
- Livewire context locking and trusted-state reduction
- **Dependencies**: Audit constitution and candidate detail document in [../audits/tenantpilot-architecture-audit-constitution.md](../audits/tenantpilot-architecture-audit-constitution.md) and [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md)
- **Priority**: high
### Queued Execution Reauthorization and Scope Continuity
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: Queued work still relies too heavily on dispatch-time actor and tenant state. Execution-time scope continuity and capability revalidation are not yet hardened as a canonical backend contract.
- **Why it matters**: This is a backend trust-gap on the mutation path. It creates the class of failure where a UI action was valid at dispatch time but the queued execution is no longer legitimate when it runs.
- **Proposed direction**: Define execution-time reauthorization, tenant operability rechecks, denial semantics, and audit visibility as a dedicated spec instead of scattering local `authorize()` patches.
- **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths
- **Priority**: high
### Tenant-Owned Query Canon and Wrong-Tenant Guards
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: Tenant isolation exists, but many reads still depend on local `tenant_id` filters instead of a reusable canonical query path. Wrong-tenant regression coverage is also uneven.
- **Why it matters**: This is isolation drift. Repeated local filtering increases the chance of future cross-tenant mistakes across resources, widgets, actions, and detail pages.
- **Proposed direction**: Define a canonical query entry pattern for tenant-owned models plus a required wrong-tenant regression matrix for tier-1 surfaces.
- **Dependencies**: Canonical tenant context work in Specs 135 and 136
- **Priority**: high
### Findings Workflow Enforcement and Audit Backstop
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: Findings lifecycle semantics are strong in the spec, but enforcement still depends too much on service-path discipline. Direct or bypassing state mutation remains too plausible.
- **Why it matters**: This is workflow-truth debt in a governance domain. If findings state can drift outside the canonical workflow path, auditability and operator trust degrade together.
- **Proposed direction**: Formalize transition enforcement and add an audit backstop so meaningful lifecycle changes cannot silently bypass the intended workflow.
- **Dependencies**: Findings workflow SLA (Spec 111), audit log foundation (Spec 134)
- **Priority**: high
### Livewire Context Locking and Trusted-State Reduction
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: Complex Livewire and Filament flows still expose ownership-relevant context in public component state without one explicit repo-wide hardening standard.
- **Why it matters**: This is a trust-boundary problem. Even without a known exploit, mutable client-visible identifiers and workflow context make future authorization and isolation mistakes more likely.
- **Proposed direction**: Define a reusable hardening pattern for locked identifiers, server-derived workflow truth, and forged-state regression tests on tier-1 component families.
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
- **Priority**: medium
### Exception / Risk-Acceptance Workflow for Findings
- **Type**: feature
- **Source**: HANDOVER gap analysis, Spec 111 follow-up

View File

@ -1,4 +1,5 @@
@php
$contextBanner = $this->canonicalContextBanner();
$pollInterval = $this->pollInterval();
@endphp
@ -9,6 +10,21 @@
x-on:visibilitychange.window="$wire.set('opsUxIsTabHidden', document.hidden)"
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
>
@if ($contextBanner !== null)
@php
$bannerClasses = match ($contextBanner['tone']) {
'amber' => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
'slate' => 'border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-500/30 dark:bg-slate-500/10 dark:text-slate-100',
default => 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100',
};
@endphp
<div class="mb-6 rounded-lg border px-4 py-3 text-sm {{ $bannerClasses }}">
<p class="font-semibold">{{ $contextBanner['title'] }}</p>
<p class="mt-1">{{ $contextBanner['body'] }}</p>
</div>
@endif
@if ($this->redactionIntegrityNote())
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $this->redactionIntegrityNote() }}

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Canonical Operation Viewer Context Decoupling
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-15
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed on 2026-03-15 after replacing the scaffolded template with a complete draft.
- The spec stays at policy and UX-contract level: it defines route legitimacy, authorization semantics, deep-link behavior, and messaging expectations without prescribing code structure.
- No clarification markers remain. The draft assumes existing canonical run links and capability registry stay in place while this feature hardens their trust semantics.

View File

@ -0,0 +1,64 @@
# Route Contracts: Canonical Operation Viewer Context Decoupling
## Scope
These contracts describe the expected request and response behavior for the canonical operations routes affected by Spec 144.
## Canonical Routes
### GET /admin/operations
| Property | Value |
|----------|-------|
| Route name | `admin.operations.index` |
| Purpose | Workspace-level operations index with optional tenant-context convenience filtering |
| Auth | Requires authenticated admin-plane user plus valid workspace membership |
| Selected tenant behavior | May default filters and return affordances when an active entitled tenant exists |
| Legitimacy rule | Index remains valid even when there is no selected tenant |
| Forbidden state | Not primary for this route; out-of-scope access is deny-as-not-found |
| Not found state | Non-member workspace access resolves as deny-as-not-found |
### GET /admin/operations/{run}
| Property | Value |
|----------|-------|
| Route name | `admin.operations.view` |
| Purpose | Canonical workspace-level record viewer for one operation run |
| Primary subject | `OperationRun` |
| Auth | Requires authenticated admin-plane user, workspace membership for the run's workspace, direct tenant entitlement when `tenant_id` is present, and any resolved capability for the run type |
| Selected tenant behavior | Selected or remembered tenant context may affect informational banners and convenience navigation only |
| Success state | 200 when the run exists and the actor is authorized |
| Not found state | 404 when the run does not exist, the actor is not a workspace member, or the actor lacks entitlement to the run's linked tenant |
| Forbidden state | 403 only when the actor is otherwise in scope but lacks the capability resolved for the run type |
| Prohibited behavior | The route must not require the selected header tenant to match the run's tenant |
## Canonical Deep-Link Contract
- All in-product run links in this scope must resolve through `admin.operations.view`.
- Canonical run links from tenant pages, notifications, verification surfaces, and monitoring surfaces must be self-sufficient: if the run is viewable, the route must open regardless of the selected tenant context in the header.
- A generating page's tenant context must not be required to reproduce the same result when the link is opened later.
## Presentation Contract
### Canonical Viewer Banner States
| State | Required Behavior |
|-------|-------------------|
| Matching selected header tenant | Viewer may omit mismatch banner |
| Different selected header tenant | Viewer remains valid and shows a non-blocking mismatch message |
| No selected tenant | Viewer remains valid and may show workspace-level framing |
| Run linked to onboarding, archived, or another selector-excluded tenant under existing availability rules | Viewer remains valid and shows lifecycle-aware framing |
| Tenantless run | Viewer remains valid and clearly indicates workspace-level scope |
### Follow-Up Navigation
- Related links or actions may be reduced, disabled, or absent when the run's tenant is not entitled or when lifecycle rules make follow-up surfaces unavailable.
- Follow-up affordances must be evaluated independently from canonical viewer legitimacy when the run tenant is onboarding, archived, or otherwise excluded from the selector by existing availability rules.
- Missing follow-up links must not be interpreted as run invalidity and must not block the canonical viewer.
## Non-Goals For This Contract
- No change to `OperationRun` ownership
- No new route family
- No automatic tenant-context switching during run viewing
- No change to operation start, progress, or completion notification contracts

View File

@ -0,0 +1,99 @@
# Data Model: Canonical Operation Viewer Context Decoupling
## Overview
This feature introduces no database schema changes. The design work is centered on clarifying how existing records and session state combine to produce canonical run-view behavior.
## Core Entities
### OperationRun
- **Ownership**: Workspace-owned canonical monitoring record
- **Existing fields used by this feature**:
- `id`
- `workspace_id`
- `tenant_id` nullable
- `type`
- `status`
- `outcome`
- `context`
- `summary_counts`
- `initiator_name`
- **Relationships**:
- Belongs to `workspace`
- Optionally belongs to `tenant`
- Optionally belongs to `user`
- **Feature rule**: Route legitimacy is derived from the run itself plus direct entitlement checks, not from remembered tenant context.
### Tenant
- **Ownership**: Workspace-owned durable record
- **Existing fields used by this feature**:
- `id`
- `workspace_id`
- `name`
- `external_id`
- lifecycle state as derived by the tenant operability and lifecycle presentation layer
- **Feature rule**: A tenant linked to a run may be active, onboarding, archived, or otherwise non-selectable as current context and still remain a valid tenant reference for the canonical run viewer.
### RememberedTenantContext
- **Ownership**: Session-backed operator preference state
- **Existing sources used by this feature**:
- Current Filament tenant when present and entitled
- Workspace remembered tenant when no entitled Filament tenant is present
- **Feature rule**: This state may influence labels, back links, and optional information banners, but it never decides whether the canonical run exists or is viewable.
## Derived Viewer State
The implementation should model a lightweight derived view state rather than adding persistence.
### CanonicalRunViewerState
- **Inputs**:
- `OperationRun $run`
- current workspace membership
- direct tenant entitlement for `$run->tenant_id` when present
- current remembered or selected header tenant context
- **Derived outputs**:
- `authorization_outcome`: allowed, deny-as-not-found, forbidden
- `run_tenant_state`: tenantless, active, onboarding, archived, other non-selectable
- `header_context_state`: no selected tenant, selected tenant matches run tenant, selected tenant differs from run tenant
- `banner_message`: null or an informational message explaining mismatch or lifecycle framing
- `follow_up_affordance_state`: available, partially available, unavailable because of lifecycle or entitlement
## Authorization State Matrix
| Run Exists | Workspace Member | Tenant Entitled | Capability Granted | Result |
|------------|------------------|-----------------|--------------------|--------|
| No | N/A | N/A | N/A | 404 not found |
| Yes | No | N/A | N/A | 404 deny-as-not-found |
| Yes | Yes | No for linked tenant | N/A | 404 deny-as-not-found |
| Yes | Yes | Yes or tenantless | No when capability is required | 403 forbidden |
| Yes | Yes | Yes or tenantless | Yes or no capability required | Render viewer |
## Presentation State Matrix
| Run Tenant | Header Tenant Context | Viewer Valid | Expected Messaging |
|------------|-----------------------|--------------|--------------------|
| Tenantless | None | Yes | Workspace-level framing only |
| Tenantless | Selected tenant present | Yes | Optional note that run is workspace-level and not tied to the selected tenant |
| Active tenant | Matching selected tenant | Yes | No mismatch banner required |
| Active tenant | Different selected tenant | Yes | Non-blocking mismatch message |
| Onboarding or archived tenant | None | Yes | Lifecycle-aware canonical workspace framing |
| Onboarding or archived tenant | Different selected tenant | Yes | Lifecycle-aware mismatch message |
## Validation Rules
- A run with `workspace_id <= 0` remains invalid and is denied as not found.
- A tenant-linked run must never reveal tenant-linked details to an actor who lacks entitlement to that tenant.
- A tenantless run must remain viewable based on workspace access and any resolved capability requirement for the run type.
- Remembered tenant context must never be mutated as a side effect of viewing a canonical run.
## No Schema Change Confirmation
- No new tables
- No new columns
- No new indexes
- No data backfill
- No migration work required

View File

@ -0,0 +1,296 @@
# Implementation Plan: Canonical Operation Viewer Context Decoupling
**Branch**: `144-canonical-operation-viewer-context-decoupling` | **Date**: 2026-03-15 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/144-canonical-operation-viewer-context-decoupling/spec.md`
## Summary
Harden the canonical operation run viewer so `/admin/operations/{run}` resolves legitimacy from the run, workspace, and direct tenant entitlement instead of remembered tenant context. The implementation keeps `OperationRunPolicy` as the authorization boundary, keeps `OperationRunLinks::tenantlessView()` as the canonical deep-link contract, and adds explicit non-blocking context messaging plus regression coverage for mismatched tenant context, onboarding or non-selectable tenants, tenantless runs, and 404 versus 403 semantics.
Key approach: preserve the existing canonical route family and viewer architecture, treat `OperateHubShell::activeEntitledTenant()` as a display and navigation input only, surface mismatch and lifecycle context in the viewer wrapper rather than in policy code, and deepen tests around the existing seams instead of introducing a new abstraction.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks`
**Storage**: PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes)
**Testing**: Pest v4 feature tests and Livewire page tests
**Target Platform**: Web admin panel running in Laravel Sail / Docker
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep operations index and canonical run viewer DB-only at render and poll time, with no external HTTP or queue side effects during page load
**Constraints**: No new tables, no ownership changes for `OperationRun`, no implicit tenant-context mutation when viewing a run, no cross-tenant leakage, and no reintroduction of selected-tenant-as-validity-gate logic
**Scale/Scope**: Focused hardening across the canonical viewer, context-resolution helpers, and approximately 6 to 10 feature tests plus one new spec-scoped regression pack
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | N/A | No inventory, snapshot, or backup semantics change |
| Read/write separation | Pass | Feature is read-only hardening of routing, authorization, and messaging |
| Graph contract path | N/A | No Graph calls or contract changes |
| Deterministic capabilities | Pass | Existing `OperationRunCapabilityResolver` remains the canonical capability source |
| RBAC-UX planes | Pass | Work stays in `/admin` canonical workspace routes; cross-plane behavior unchanged |
| Workspace isolation | Pass | `OperationRunPolicy` already denies non-members as not found and remains the primary gate |
| Tenant isolation | Pass | Tenant-linked runs still require direct tenant entitlement; remembered context does not bypass this |
| RBAC 404 vs 403 semantics | Pass | Non-member or non-entitled remains 404; member missing resolved capability remains 403 |
| Destructive confirmation | Pass | No new destructive actions; existing `Resume capture` confirmation remains unchanged |
| Global search safety | Pass | No global-search expansion; canonical run access remains direct-route and deep-link focused |
| Run observability | Pass | Existing `OperationRun` usage remains unchanged; monitoring pages stay DB-only |
| Ops-UX 3-surface feedback | Pass | No new operation start or completion behavior is introduced |
| Ops-UX lifecycle ownership | Pass | No status or outcome transition logic is modified |
| Ops-UX summary counts | Pass | No `summary_counts` producer or consumer contract change |
| Ops-UX guards | Pass | Existing guards stay applicable; this feature adds visibility and authorization regressions |
| Data minimization | Pass | No new stored payloads or exposed secrets |
| Badge semantics | Pass | Any lifecycle or status presentation remains centralized; no ad hoc badge mapping required |
| UI naming | Pass | Vocabulary remains `View run`, `Back to Operations`, and explicit tenant-context messaging |
| Filament Action Surface Contract | Pass | Existing action surfaces remain intact; only informational messaging and route legitimacy are hardened |
| Filament UX-001 | Pass | Viewer remains an infolist-based detail page and operations remains a table page; no layout regressions required |
**Post-design re-check**: Pass. Phase 1 artifacts preserve the same constraints: no schema changes, no new auth plane, no new mutation surface, and no bypass of the existing policy and capability resolver.
## Project Structure
### Documentation (this feature)
```text
specs/144-canonical-operation-viewer-context-decoupling/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── routes.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Pages/
│ └── Operations/
│ └── TenantlessOperationRunViewer.php # Canonical viewer mount, header actions, derived banner state
├── Policies/
│ └── OperationRunPolicy.php # Direct workspace and tenant entitlement gate
├── Support/
│ ├── OperateHub/
│ │ └── OperateHubShell.php # Active entitled tenant resolution for display affordances only
│ ├── Middleware/
│ │ └── EnsureFilamentTenantSelected.php # Canonical route exemption and Livewire update safety
│ ├── Operations/
│ │ └── OperationRunCapabilityResolver.php # Capability lookup for 403 semantics on view
│ ├── Tenants/
│ │ └── TenantPageCategory.php # Canonical route page-category classification
│ └── OperationRunLinks.php # Canonical deep-link and related-link contract
resources/
└── views/
└── filament/pages/operations/
└── tenantless-operation-run-viewer.blade.php # Non-blocking context/lifecycle banner wrapper
tests/
├── Feature/
│ ├── Operations/
│ │ └── TenantlessOperationRunViewerTest.php
│ ├── Monitoring/
│ │ ├── OperationsTenantScopeTest.php
│ │ ├── OperationsCanonicalUrlsTest.php
│ │ ├── HeaderContextBarTest.php
│ │ └── OperationRunResolvedReferencePresentationTest.php
│ ├── OpsUx/
│ │ ├── OperateHubShellTest.php
│ │ ├── CanonicalViewRunLinksTest.php
│ │ └── NonLeakageWorkspaceOperationsTest.php
│ ├── Filament/
│ │ └── OperationRunEnterpriseDetailPageTest.php
│ ├── Verification/
│ │ └── VerificationAuthorizationTest.php
│ ├── RunAuthorizationTenantIsolationTest.php
│ └── 144/
│ ├── CanonicalOperationViewerContextMismatchTest.php
│ └── CanonicalOperationViewerDeepLinkTrustTest.php
└── Unit/
└── Support/
└── CanonicalNavigationContextTest.php # Existing canonical-link helper coverage, likely unchanged
```
**Structure Decision**: Standard Laravel monolith. The implementation is centered on one existing Filament page, one policy, one shell helper, one middleware path exemption, and focused Pest regressions. New spec-specific tests should live in `tests/Feature/144/` while existing coverage is extended where behavior is already anchored.
## Complexity Tracking
> No constitution violations require justification.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | N/A | N/A |
---
## Implementation Phases
### Phase A — Lock The Canonical Viewer Legitimacy Boundary
**Goal**: Ensure the viewer remains authorized from the run, workspace, and direct tenant entitlement only, with no hidden selected-tenant validity gate.
**Risk**: Medium — the current code mostly follows this rule already, so changes must not accidentally weaken legitimate tenant entitlement checks.
**Tests first**: Existing authorization tests plus a new mismatch-success regression.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Policies/OperationRunPolicy.php` | Confirm direct run-based entitlement path remains canonical; only adjust if tests expose hidden coupling or 403 vs 404 drift |
| A.2 | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep `mount()` policy-first; add derived viewer-context state helpers if needed instead of inline header-state branching |
| A.3 | `tests/Feature/RunAuthorizationTenantIsolationTest.php` | Extend tenant-entitlement 404 coverage for canonical run detail |
| A.4 | `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` | Add mismatched remembered/header tenant success path and tenantless-run success path |
**Exit criteria**: Canonical run validity is demonstrably independent of remembered tenant context, while direct tenant entitlement still governs tenant-linked runs.
### Phase B — Add Explicit Context And Lifecycle Messaging
**Goal**: Replace silent punitive behavior with a non-blocking informational surface when header tenant context differs or the run references onboarding, archived, selector-excluded, or no tenant.
**Risk**: Medium — the banner must be informative only and must not become a second authorization gate or produce conflicting messages.
**Tests first**: Enterprise detail rendering tests plus new spec-specific message assertions.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Add computed banner and follow-up-affordance payload describing run tenant, current header tenant, tenantless state, or selector-excluded lifecycle state |
| B.2 | `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Render the non-blocking canonical context banner above the infolist, alongside the existing redaction integrity note |
| B.3 | `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Assert mismatch messaging renders without displacing existing run summary sections |
| B.4 | `tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php` | Add dedicated coverage for mismatch, onboarding, archived, selector-excluded, and tenantless informational states plus reduced follow-up actions |
**Exit criteria**: Operators get explicit context and lifecycle framing, plus lifecycle-safe follow-up action treatment, without blocking page access or confusing mismatch with access failure.
### Phase C — Keep Display Affordances Separate From Legitimacy
**Goal**: Ensure `OperateHubShell`, header actions, and middleware exemptions remain convenience-only behavior for canonical routes.
**Risk**: Medium — Livewire update requests and stale remembered tenants are the easiest places for context drift to reappear.
**Tests first**: Existing `OperateHubShellTest`, header-context tests, and canonical route tests.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Support/OperateHub/OperateHubShell.php` | Keep `activeEntitledTenant()` as the display/navigation source only; adjust helper wording or fallback behavior if banner requirements expose ambiguity |
| C.2 | `app/Support/Middleware/EnsureFilamentTenantSelected.php` | Verify canonical route and `/livewire/update` exemptions remain hands-off for the run viewer; only adjust if tests show re-entry side effects |
| C.3 | `tests/Feature/OpsUx/OperateHubShellTest.php` | Extend remembered-vs-Filament tenant resolution assertions for canonical run routes |
| C.4 | `tests/Feature/Monitoring/HeaderContextBarTest.php` | Add or update expectations for canonical-run pages with mismatched tenant context |
**Exit criteria**: Header labels, return actions, and remembered tenant cleanup behave as convenience features only and never invalidate the viewer.
### Phase D — Preserve Canonical Deep-Link Trust
**Goal**: Guarantee in-product `View run` links remain canonical and self-sufficient regardless of the source surface.
**Risk**: Low — helpers already point to the canonical route, but coverage must include tenant, notification-style, and verification-surface entry points plus selector-excluded tenant scenarios.
**Tests first**: Existing canonical-link tests plus new deep-link trust regression.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Support/OperationRunLinks.php` | Keep canonical `tenantlessView()` contract and make any touched related-link semantics explicitly lifecycle-safe for selector-excluded tenants |
| D.2 | `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` | Preserve canonical route helper expectations |
| D.3 | `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` | Extend direct-route coverage for onboarding, archived, or selector-excluded tenant-linked runs and verification-surface entry points |
| D.4 | `tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php` | Add tenant-page, notification-style, and monitoring or verification deep-link coverage under changed or missing header context |
**Exit criteria**: Canonical run links remain trustworthy from tenant pages, notification-style entry points, verification surfaces, and monitoring entry points.
### Phase E — Regression Sweep And Guard Alignment
**Goal**: Leave behind a focused regression pack that keeps Spec 143 and Spec 144 semantics enforceable in CI.
**Risk**: Low — primarily additive tests and minor expectation updates.
**Tests first**: New spec pack and touched existing tests.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php` | Positive path matrix: matching tenant, mismatched tenant, no selected tenant, onboarding tenant, archived or selector-excluded tenant, tenantless run, and reduced follow-up action case |
| E.2 | `tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php` | Deep-link matrix from tenant page, notification-style, and workspace verification or monitoring surfaces |
| E.3 | Existing touched tests | Preserve 404 vs 403 semantics, DB-only rendering, and enterprise detail layout expectations |
| E.4 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required verification before moving to tasks or implementation |
**Exit criteria**: CI-relevant coverage exists for the canonical trust contract and no pre-existing canonical-view protections regress.
---
## Key Design Decisions
### D-001 — Keep `OperationRunPolicy` as the canonical authorization boundary
The viewer must continue to authorize from the run and its direct relationships, not from selected tenant state. That keeps 404 versus 403 semantics explicit and prevents header context from becoming a second hidden gate.
**See**: [research.md](research.md) R-001
### D-002 — Use `OperateHubShell` only for display and navigation affordances
`activeEntitledTenant()` already models the current header context for workspace-scoped pages. The plan preserves that role but explicitly keeps it out of route legitimacy, which limits changes to messaging and return affordances.
**See**: [research.md](research.md) R-002
### D-003 — Put context-mismatch UX in the viewer wrapper, not in policy code
Mismatch and lifecycle messaging are presentation concerns. Rendering them in the Blade wrapper above the existing infolist minimizes code churn and avoids contaminating the authorization layer or `OperationRunResource::infolist()` reuse.
**See**: [research.md](research.md) R-003
### D-004 — Preserve the existing canonical deep-link helper contract
`OperationRunLinks::tenantlessView()` is already the canonical route generator and should stay authoritative. The feature strengthens trust and coverage around that contract rather than inventing a second route family or source-aware URL rules.
**See**: [research.md](research.md) R-004
### D-005 — Keep the solution reusable for future canonical viewers
The viewer hardening must stay expressed as canonical workspace-viewer semantics rather than as an operations-only exception. Any touched middleware, page-category, or viewer-state rules should remain generic enough to support future canonical workspace-level record viewers without changing ownership or introducing a second classification path.
**See**: [spec.md](spec.md) FR-144-020
---
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Hidden tenant-context coupling remains in middleware or page mount logic | Medium | Medium | Add route, Livewire update, and stale remembered-tenant regressions before changing behavior |
| Viewer messaging accidentally implies entitlement where follow-up links are unavailable | Medium | Medium | Separate “run is viewable” from “tenant follow-up actions may be unavailable” in banner text, affordance gating, and tests |
| Authorization changes drift from `OperationRunCapabilityResolver` semantics | Medium | Low | Keep policy and capability resolver together; add 404 vs 403 matrix tests |
| Existing enterprise detail layout regresses when banner is added | Low | Medium | Reuse current wrapper and extend enterprise detail page tests |
| Deep-link trust is hardened only for one entry point | Medium | Medium | Add spec-specific deep-link coverage from tenant, notification-style, and workspace verification or monitoring surfaces |
---
## Test Strategy
### New Tests (spec-specific in `tests/Feature/144/`)
| Test ID | File | Coverage |
|---------|------|----------|
| T-144-001 | `CanonicalOperationViewerContextMismatchTest.php` | Authorized run remains viewable when current header tenant differs from run tenant |
| T-144-002 | `CanonicalOperationViewerContextMismatchTest.php` | Tenantless run renders with workspace framing and no selected tenant requirement |
| T-144-003 | `CanonicalOperationViewerContextMismatchTest.php` | Onboarding, archived, or selector-excluded tenant-linked run remains viewable for authorized actors |
| T-144-004 | `CanonicalOperationViewerContextMismatchTest.php` | Non-entitled tenant-linked run remains deny-as-not-found |
| T-144-005 | `CanonicalOperationViewerDeepLinkTrustTest.php` | Tenant-detail deep link opens canonical viewer under mismatched header context |
| T-144-006 | `CanonicalOperationViewerDeepLinkTrustTest.php` | Monitoring, notification-style, or verification-surface deep link opens canonical viewer with no selected tenant |
| T-144-007 | `CanonicalOperationViewerContextMismatchTest.php` | Lifecycle-safe follow-up actions remain reduced or absent without invalidating the canonical run viewer |
### Existing Tests To Extend
| File | Purpose |
|------|---------|
| `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` | Core canonical viewer behavior and Livewire page semantics |
| `tests/Feature/OpsUx/OperateHubShellTest.php` | Remembered tenant, Filament tenant, and return-affordance resolution |
| `tests/Feature/Monitoring/OperationsTenantScopeTest.php` | Operations index tenant prefilter and canonical detail 404 isolation |
| `tests/Feature/RunAuthorizationTenantIsolationTest.php` | Direct tenant-entitlement isolation on canonical run routes |
| `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Detail-page structure and contextual content rendering |
| `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` | Canonical URL trust across operations surfaces |
### Focused Verification Command
```bash
vendor/bin/sail artisan test --compact \
tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php \
tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php \
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
tests/Feature/OpsUx/OperateHubShellTest.php \
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
tests/Feature/RunAuthorizationTenantIsolationTest.php \
tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
```

View File

@ -0,0 +1,66 @@
# Quickstart: Canonical Operation Viewer Context Decoupling
## Goal
Verify that canonical operation run viewing is independent of remembered tenant context while preserving tenant entitlement and capability semantics.
## Preconditions
1. Start the local environment:
```bash
vendor/bin/sail up -d
```
2. Ensure test database and app state are ready:
```bash
vendor/bin/sail artisan optimize:clear
```
## Manual Verification Flow
1. Sign in as a user who is a member of one workspace and entitled to at least two tenants in that workspace.
2. Open a canonical run linked to tenant A while tenant B is selected in the header.
3. Confirm the page still renders the run and shows a non-blocking mismatch message.
4. Clear tenant context or open the same run from a fresh session with no selected tenant.
5. Confirm the run still renders.
6. Open a tenantless run.
7. Confirm the page renders with workspace-level framing and no tenant selection requirement.
8. Open the same run from a notification-style or verification-surface `View run` entry point with no selected tenant.
9. Confirm the canonical viewer still resolves the same run.
10. Open a run linked to an onboarding, archived, or other tenant state already excluded from selector rules.
11. Confirm the page remains viewable, lifecycle-aware messaging is shown, and tenant follow-up actions are reduced or absent without blocking the viewer.
12. Open a canonical run for a tenant the current user is not entitled to.
13. Confirm the response is deny-as-not-found.
14. Open a run type that resolves a capability the current user lacks while workspace and tenant scope are otherwise valid.
15. Confirm the response is forbidden.
## Focused Test Command
```bash
vendor/bin/sail artisan test --compact \
tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php \
tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php \
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
tests/Feature/OpsUx/OperateHubShellTest.php \
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
tests/Feature/RunAuthorizationTenantIsolationTest.php \
tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
```
## Formatting
Run the required formatter after implementation changes:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
## Expected Outcome
- Canonical run viewing succeeds for authorized users regardless of remembered tenant context mismatch.
- Tenant-linked runs still enforce direct tenant entitlement.
- Tenantless runs and onboarding, archived, or otherwise selector-excluded tenant-linked runs remain viewable when authorized.
- `View run` deep links remain canonical and trustworthy across tenant, notification, verification, and monitoring surfaces.
- Reduced or unavailable tenant follow-up actions do not invalidate the canonical viewer.

View File

@ -0,0 +1,41 @@
# Research: Canonical Operation Viewer Context Decoupling
## R-001: Authorization must remain run-first and direct-entitlement based
- **Decision**: Keep `OperationRunPolicy::view()` as the canonical authorization boundary for `/admin/operations/{run}` and keep its logic based on the run's workspace membership, direct tenant entitlement, and capability resolution by run type.
- **Rationale**: The existing policy already encodes the correct 404 versus 403 semantics: workspace non-members and users lacking entitlement to the run's tenant receive deny-as-not-found, while in-scope users missing a resolved capability receive forbidden. This matches Spec 143 and Spec 144 directly and avoids scattering authorization across header-context helpers.
- **Alternatives considered**:
- Move authorization into `TenantlessOperationRunViewer::mount()`: rejected because it would duplicate policy logic and increase the chance of 404 versus 403 drift.
- Authorize from selected tenant context instead of the run's tenant: rejected because that is the failure mode the spec is explicitly correcting.
## R-002: `OperateHubShell` should remain a display and navigation helper only
- **Decision**: Treat `OperateHubShell::activeEntitledTenant()` as the source for header labels, return affordances, and optional banner context, but not as a precondition for canonical viewer legitimacy.
- **Rationale**: `OperateHubShell` already resolves the current admin-plane tenant context in a way that respects route tenant, Filament tenant, and remembered tenant fallbacks. That makes it useful for describing the operator's current context, but using it as an authorization or route-validity gate would reintroduce the same hidden coupling Spec 144 is removing.
- **Alternatives considered**:
- Ignore active tenant context completely on the viewer: rejected because the spec explicitly requires non-blocking transparency when current header context differs from the run tenant.
- Force the viewer to switch the remembered tenant to the run's tenant: rejected because it would turn a read action into an implicit context mutation and violate the spec's convenience-only rule for remembered context.
## R-003: Context mismatch UX belongs in the viewer wrapper, not in policy or infolist definitions
- **Decision**: Add canonical-context messaging in `TenantlessOperationRunViewer` and render it in `tenantless-operation-run-viewer.blade.php` above the reused infolist.
- **Rationale**: The current viewer wrapper already renders the redaction-integrity note before the infolist and owns polling and page-level concerns. Adding a second informational banner there is low-risk and preserves reuse of `OperationRunResource::infolist()` without injecting presentation logic into policy code or shared resource schema builders.
- **Alternatives considered**:
- Inject banner rows directly into `OperationRunResource::infolist()`: rejected because the infolist is shared and the banner is specific to the canonical workspace viewer semantics.
- Encode mismatch state in policy responses: rejected because authorization responses should stay limited to allow, 404, and 403 semantics.
## R-004: Preserve the existing canonical deep-link helper contract
- **Decision**: Keep `OperationRunLinks::tenantlessView()` and the `admin.operations.view` route as the only canonical run-view target for this feature.
- **Rationale**: Existing tests and notification surfaces already rely on this helper, and `OperationRunLinks::view()` already normalizes tenant-aware callers onto the tenantless canonical route. The problem is not URL shape; it is trust in the viewer after the URL is opened.
- **Alternatives considered**:
- Introduce a new source-aware deep-link helper: rejected because it would fragment the canonical route contract.
- Reintroduce tenant-bound operation detail URLs: rejected because Spec 078 already consolidated operations onto the canonical tenantless route family.
## R-005: Extend focused existing tests and add a small spec-specific pack
- **Decision**: Extend the strongest existing viewer, shell, authorization, and canonical-link tests, and add a small `tests/Feature/144/` pack for mismatch and deep-link trust scenarios.
- **Rationale**: The repo already has substantial coverage around `TenantlessOperationRunViewer`, `OperateHubShell`, operations canonical URLs, and tenant isolation. Reusing those anchors reduces new-test duplication while still giving Spec 144 a clear regression pack that maps directly to the acceptance criteria.
- **Alternatives considered**:
- Put all coverage in one large existing test file: rejected because the matrix of mismatch, lifecycle, and deep-link cases becomes harder to scan and maintain.
- Add only spec-specific tests with no existing-test updates: rejected because that would miss the places where current semantics are already encoded and likely to regress.

View File

@ -0,0 +1,185 @@
# Feature Specification: Canonical Operation Viewer Context Decoupling
**Feature Branch**: `144-canonical-operation-viewer-context-decoupling`
**Created**: 2026-03-14
**Status**: Draft
**Input**: User description: "Canonical operation-run viewing currently fails or becomes misleading when remembered tenant context does not match the operation run's referenced tenant, especially for onboarding and non-active tenant scenarios."
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- `/admin/operations`
- `/admin/operations/{run}`
- `/admin/tenants/{tenant}`
- Any in-product deep link that resolves to `/admin/operations/{run}`
- **Data Ownership**:
- Operation runs remain canonical workspace-owned records.
- Operation runs may reference a tenant, but they are not subordinate to remembered tenant context.
- Tenant lifecycle may affect presentation and follow-up affordances, but not the existence of a canonical run.
- This feature introduces no new tables and does not change the workspace-first ownership model.
- **RBAC**:
- Authorization planes involved: tenant/admin `/admin` canonical workspace routes and tenant entitlement checks for referenced tenants.
- Workspace non-members or users lacking entitlement to a referenced tenant must receive deny-as-not-found behavior.
- Workspace members who are in scope but lack the required capability to inspect operation history must receive forbidden behavior.
- Authorization must be determined from the run, workspace relationship, actor, and referenced tenant entitlement, never from remembered tenant context equality.
- This spec is a focused implementation slice of Spec 143's canonical workspace-level record viewer rules.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: `/admin/operations` may prefilter to the currently selected tenant as a convenience, but the filter is optional state and clearing or changing it must not affect whether `/admin/operations/{run}` is legitimate.
- **Explicit entitlement checks preventing cross-tenant leakage**: The canonical run viewer must verify the run belongs to the active workspace, verify tenant entitlement directly against the run's referenced tenant when one exists, and withhold tenant-linked details from any actor who is not entitled to that tenant. Remembered tenant context, including the currently selected header tenant, must never substitute for these checks.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Open A Canonical Run Reliably (Priority: P1)
As a workspace operator, I need `/admin/operations/{run}` to open whenever the run exists and I am authorized, even if my remembered tenant context points at another tenant, so that operation history behaves like a trustworthy monitoring surface.
**Why this priority**: This is the core trust failure. If valid runs appear missing because of unrelated header state, incident investigation and operator confidence both break.
**Independent Test**: Can be fully tested by opening an authorized run while the header points to a different tenant and confirming the run renders, authorization is enforced correctly, and the page shows an informational mismatch state instead of failing.
**Acceptance Scenarios**:
1. **Given** an authorized user opens a valid run linked to tenant A while tenant B is selected in the header, **When** the canonical viewer loads, **Then** the run renders successfully and the mismatch is treated as informational context rather than record invalidity.
2. **Given** an authorized user opens a valid run with no tenant reference, **When** the canonical viewer loads, **Then** the viewer renders as a workspace-level run without requiring any selected tenant context.
3. **Given** a run does not exist, **When** a user opens `/admin/operations/{run}`, **Then** the system returns the normal not-found outcome.
---
### User Story 2 - Trust Deep Links From Other Surfaces (Priority: P1)
As a workspace operator, I need run links from tenant pages, notifications, verification surfaces, and future alerts to resolve to the same authoritative viewer regardless of current header state, so that any View run link is dependable.
**Why this priority**: Canonical links lose their value if they only work when generated and opened under matching remembered context.
**Independent Test**: Can be fully tested by opening canonical run links generated from a tenant detail page, a notification-style entry point, and a workspace verification or monitoring surface while switching or clearing header context between requests.
**Acceptance Scenarios**:
1. **Given** a tenant detail surface links to a canonical run, **When** the operator opens that link with a different tenant selected, **Then** the canonical viewer still opens the same run.
2. **Given** a notification or monitoring widget links to a canonical run, **When** the operator opens that link without any selected tenant, **Then** the canonical viewer still resolves from the run and authorization rules alone.
3. **Given** a valid run references an onboarding, archived, or other tenant state already excluded by existing selector rules, **When** an authorized operator opens a canonical link to that run, **Then** the viewer remains accessible and does not require that tenant to be selectable in the standard tenant switcher.
---
### User Story 3 - Understand Context And Lifecycle Without Being Blocked (Priority: P2)
As a workspace operator, I need the viewer to explain tenant-context mismatch and tenant lifecycle state clearly, so that I understand why some follow-up actions may differ without confusing that state with access failure.
**Why this priority**: Operators should not have to infer from a 404 whether the problem is entitlement, lifecycle, or just mismatched context.
**Independent Test**: Can be fully tested by opening canonical runs for active, onboarding, archived, selector-excluded, and tenantless runs while varying remembered tenant context and verifying the viewer uses non-blocking explanatory messaging plus lifecycle-safe follow-up affordances.
**Acceptance Scenarios**:
1. **Given** the selected tenant differs from the run's tenant, **When** the run viewer loads, **Then** the page shows a non-blocking message explaining the mismatch.
2. **Given** the run's tenant is onboarding, archived, or otherwise excluded from the tenant selector by existing availability rules, **When** the canonical viewer loads for an authorized operator, **Then** the page shows lifecycle-aware context, keeps the run viewable, and does not over-promise tenant follow-up actions.
3. **Given** the operator is authorized for the workspace but not entitled to the run's referenced tenant, **When** the operator opens the canonical run route, **Then** the system returns deny-as-not-found rather than a misleading mismatch message.
### Edge Cases
- A remembered tenant may be stale, archived, cleared, or otherwise ineligible for current tenant selection; that state must not invalidate an unrelated canonical run.
- A canonical run may reference a tenant that is onboarding, archived, or otherwise excluded from the normal tenant selector by existing availability rules; the run remains valid if entitlement allows it.
- A canonical run may have no tenant reference at all and still must remain viewable as a workspace record.
- A deep link may open the run viewer before the current browser session has selected any tenant context.
- Follow-up links from the run viewer back into tenant surfaces may be unavailable because of tenant lifecycle rules even when the run itself is valid.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, and no new scheduled or queued work. It hardens the semantics of an existing canonical monitoring record so that route legitimacy, authorization, and tenant-reference handling stay explicit, testable, and audit-safe.
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` records but does not create a new run type and does not change run lifecycle ownership. The existing three-surface feedback contract remains unchanged. `OperationRun.status` and `OperationRun.outcome` remain service-owned, `summary_counts` rules remain unchanged, and this feature adds regression coverage around canonical run visibility rather than around run transitions or notifications.
**Constitution alignment (RBAC-UX):** This feature changes authorization behavior within the admin plane. Non-members or users lacking workspace or tenant entitlement remain deny-as-not-found. Members who are in scope but lack the required capability to inspect operation history remain forbidden. Authorization must be enforced server-side from workspace membership, tenant entitlement on the run's referenced tenant, and capability policy checks. No raw capability strings or role-name checks may be introduced. Global search and direct record access must remain tenant-safe and non-member-safe. No destructive action is introduced by this spec.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Monitoring and operation viewing continue to avoid auth-handshake exceptions and do not rely on synchronous auth-path behavior.
**Constitution alignment (BADGE-001):** This feature may change lifecycle or context presentation inside the viewer, so lifecycle and run-status badges must continue to come from centralized badge semantics rather than page-local mappings.
**Constitution alignment (UI-NAMING-001):** The target object is the operation run. Primary operator verbs remain `View run`, `Back to Operations`, and existing follow-up labels already used by related navigation. The new messaging must use domain language such as `current tenant context`, `run tenant`, and `canonical workspace view`, and must avoid implying that mismatched context makes the run invalid.
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament operations screens. The Action Surface Contract remains satisfied because the action inventory is not materially expanded; the change is to route legitimacy, messaging, and authorization semantics. The matrix below documents the in-scope surfaces.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The operations index remains a table-driven monitoring page and the canonical run page remains a view/detail surface. This feature preserves the current layouts while adding explicit, non-blocking context messaging and lifecycle-aware framing. No create or edit layout is introduced in this scope.
### Functional Requirements
- **FR-144-001**: The system MUST classify `/admin/operations/{run}` as a canonical workspace-level record viewer whose primary subject is the operation run.
- **FR-144-002**: The system MUST determine canonical run-view legitimacy from the run's existence, workspace relationship, actor authorization, and referenced tenant entitlement when a tenant is present.
- **FR-144-003**: The system MUST NOT require the remembered tenant context, including the currently selected header tenant, to match the run's tenant in order to render the canonical run viewer.
- **FR-144-004**: If a run exists and the actor is authorized, the system MUST render the canonical run viewer even when the selected tenant differs from the run's tenant.
- **FR-144-005**: If a run exists and references no tenant, the system MUST render the canonical viewer as a workspace-level run without requiring tenant context.
- **FR-144-006**: If a run exists and references an onboarding, archived, or otherwise selector-excluded tenant under existing availability rules, the system MUST keep the run viewable for authorized actors and MUST NOT require that tenant to be selectable as active context.
- **FR-144-007**: If the actor lacks workspace membership or lacks entitlement to the run's referenced tenant, the system MUST return deny-as-not-found and MUST NOT reveal tenant-linked details.
- **FR-144-008**: If the actor is in scope but lacks the required capability to inspect operation history, the system MUST return forbidden rather than masking the failure as a context mismatch.
- **FR-144-009**: The viewer MUST distinguish between not-found, authorization failure, and tenant-context mismatch, and tenant-context mismatch MUST never masquerade as not-found.
- **FR-144-010**: The viewer MUST display the run's referenced tenant clearly when one exists and MUST frame tenantless runs clearly as workspace-level runs.
- **FR-144-011**: When the currently selected header tenant differs from the run's tenant, the viewer MUST display a non-blocking informational message explaining the mismatch.
- **FR-144-012**: When no tenant context is selected, the viewer MAY display workspace-view framing, but it MUST NOT require tenant selection to proceed.
- **FR-144-013**: Tenant lifecycle state for the run's referenced tenant MUST influence informational messaging and follow-up affordances, but MUST NOT determine whether the canonical run page exists.
- **FR-144-014**: All in-product deep links to operation runs within this scope MUST resolve to the canonical operation viewer route and MUST remain viewable because the run is viewable, not because the generating page shared the same remembered tenant context.
- **FR-144-015**: Return navigation, back links, and tenant-prefilter behavior on `/admin/operations` MUST be treated as convenience behavior only and MUST NOT reintroduce selected-tenant-as-validity-gate logic.
- **FR-144-016**: The canonical run viewer MUST NOT silently switch the user's remembered tenant context to match the run's tenant as an implicit side effect of viewing the page.
- **FR-144-017**: Any follow-up links or actions from the canonical run viewer into tenant surfaces MUST remain lifecycle-safe and authorization-safe, so a valid run can coexist with unavailable or reduced tenant follow-up actions.
- **FR-144-018**: New code in scope MUST NOT use selected-tenant equality as an authorization shortcut, route precondition, or record-resolution gate for canonical run viewing.
- **FR-144-019**: Regression coverage for this feature MUST include matched tenant context, mismatched tenant context, no selected tenant, onboarding tenant run, archived or selector-excluded tenant run, and tenantless run scenarios, plus at least one positive and one negative authorization case and one lifecycle-safe follow-up-action case.
- **FR-144-020**: This feature's solution pattern MUST remain reusable for future canonical workspace-level record viewers without changing operation-run ownership or introducing a new page category.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations index | `/admin/operations` | Existing scope indicator plus `Back to {tenant}` or origin return when context provides it, and `Show all tenants` when tenant context is active | Canonical run links from row inspection | `View run` | Existing grouped bulk actions unchanged in this spec | Existing empty state unchanged in this spec | Not applicable | Not applicable | No new audit event | This feature preserves the existing action inventory and only clarifies that tenant prefilter state is convenience behavior, not canonical-record validity. |
| Canonical operation run viewer | `/admin/operations/{run}` | Existing scope indicator, `Back to Operations` or origin return, `Show all operations` when tenant context is active, `Refresh`, existing `Open` related links, and existing `Resume capture` when already valid | Route-record detail page | None | None | Not applicable | Same as header actions listed for this viewer | Not applicable | No new audit event in this spec | Action Surface Contract remains satisfied. `Resume capture` keeps its existing confirmation and authorization rules; this feature changes page legitimacy and informational context, not mutation semantics. |
### Key Entities *(include if feature involves data)*
- **Operation Run**: A canonical workspace-owned monitoring record that may optionally reference a tenant and remains authoritative on its own route.
- **Referenced Tenant**: The tenant linked from an operation run, whose entitlement and lifecycle influence viewer details and follow-up affordances but not the existence of the run itself.
- **Remembered Tenant Context**: Operator preference state used for convenience filtering and navigation, but not as an authorization source or route-legitimacy requirement.
- **Canonical Run Deep Link**: Any in-product link that opens `/admin/operations/{run}` from a tenant page, notification, monitoring surface, or future alerting surface.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-144-001**: In focused regression coverage, 100% of authorized operation-run links open successfully for matched tenant context, mismatched tenant context, and no selected tenant context scenarios.
- **SC-144-002**: In focused regression coverage, 0 authorized canonical run views fail with false not-found behavior solely because remembered tenant context differs from the run's tenant.
- **SC-144-003**: In focused regression coverage, 100% of non-member or non-entitled access attempts resolve as deny-as-not-found, and 100% of in-scope capability denials resolve as forbidden.
- **SC-144-004**: In focused regression coverage, 100% of authorized runs linked to onboarding, archived, or other selector-excluded tenants under existing availability rules remain viewable through the canonical route.
- **SC-144-005**: In focused regression coverage, 100% of tenantless runs remain viewable through the canonical route without requiring tenant selection.
- **SC-144-006**: In focused UX validation, every covered tenant-context mismatch case shows explicit non-blocking context messaging instead of an ambiguous failure state.
- **SC-144-007**: All in-scope `View run` entry points continue to resolve to the canonical run viewer route and do not require page-local tenant-coupled URLs.
## Assumptions
- This spec refines and implements the canonical-viewer semantics established by Spec 143 rather than redefining the broader tenant lifecycle model.
- The existing canonical operation viewer route remains `/admin/operations/{run}`.
- Existing run links already target the canonical route family and this feature hardens their trust contract rather than introducing a new link family.
- Existing capability registry and policy structure already define the capability needed to inspect operation history.
- Lifecycle-safe follow-up actions for tenant surfaces may be refined by later specs, but this feature must not let missing follow-up actions invalidate the run viewer itself.
## Risks
- Hidden tenant-context coupling may remain in route resolution, page mount logic, or shared shell helpers if the change only addresses the most visible failure path.
- Over-correcting into tenant blindness could weaken legitimate tenant entitlement checks for tenant-linked runs.
- Follow-up links from the canonical viewer may still confuse operators if they continue to assume active tenant context rather than lifecycle-safe tenant access.
- Future canonical viewers may repeat the same anti-pattern if this feature is treated as an operations-only exception instead of a reusable rule.
## Follow-Up Dependencies
- Spec 145 — Tenant Action Taxonomy and Lifecycle-Safe Visibility
- Spec 146 — Central Tenant Status Presentation
- Spec 147 — Tenant Selector and Remembered Context Enforcement
- Spec 148 — Central Tenant Operability Policy
## Final Direction
The operation-run viewer must behave like a true canonical workspace-level record page. The run is authoritative, the workspace relationship matters, authorization matters, and the referenced tenant matters, but remembered tenant context does not determine page legitimacy. This is the required trust model for enterprise monitoring and the correct pattern for future canonical record viewers.

View File

@ -0,0 +1,188 @@
# Tasks: Canonical Operation Viewer Context Decoupling
**Input**: Design documents from `/specs/144-canonical-operation-viewer-context-decoupling/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/routes.md, quickstart.md
**Tests**: Required. This feature changes runtime behavior in canonical routing, authorization, and viewer UX, so Pest coverage must be added or updated before implementation is considered complete.
**Operations**: No new `OperationRun` type, no new queued work, and no changes to the Ops-UX notification contract are introduced. Existing canonical `OperationRun` behavior and `View run` routing remain in place.
**RBAC**: This feature changes authorization behavior in the admin plane. Tasks must preserve explicit 404 vs 403 semantics, use existing Gate or Policy enforcement, and avoid raw capability strings or role checks.
**UI Naming**: Any added banner or helper copy must preserve existing operator-facing vocabulary around `View run`, `Back to Operations`, current tenant context, and canonical workspace view.
**Filament UI Action Surfaces**: Existing action surfaces remain in scope. No new destructive action is introduced; existing `Resume capture` confirmation behavior must remain intact.
**Filament UI UX-001**: The canonical run page remains an infolist-based detail surface and the operations index remains a table surface; tasks must preserve that layout.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the spec-specific regression test files and implementation workspace for Spec 144.
- [X] T001 Create the spec-specific Pest files `tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php` and `tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock the shared canonical-route and context-resolution baseline before user story work begins.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T002 [P] Extend `tests/Feature/OpsUx/OperateHubShellTest.php` with canonical `/admin/operations/{run}` remembered-vs-Filament tenant resolution coverage
- [X] T003 [P] Extend `tests/Feature/Monitoring/HeaderContextBarTest.php` with canonical run-page header context expectations for mismatched and missing tenant context
- [X] T004 Update `app/Support/Middleware/EnsureFilamentTenantSelected.php` and `app/Support/OperateHub/OperateHubShell.php` so canonical `/admin/operations/{run}` and related `/livewire/update` requests remain convenience-only, never force tenant-selection validity gates, and preserve a reusable canonical-viewer rule for future workspace-level record viewers
**Checkpoint**: Canonical route guardrails and context-resolution baselines are in place for all stories.
---
## Phase 3: User Story 1 - Open A Canonical Run Reliably (Priority: P1) 🎯 MVP
**Goal**: Keep canonical run viewing valid whenever the run exists and the actor is authorized, regardless of remembered tenant-context mismatch.
**Independent Test**: Open `/admin/operations/{run}` for an authorized tenant-linked run while a different tenant is selected, and verify the viewer renders. Open a tenantless run and verify it also renders. Verify non-entitled access remains 404 and in-scope capability denial remains 403.
### Tests for User Story 1
- [X] T005 [P] [US1] Extend `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` with mismatched-header-tenant success coverage and tenantless-run success coverage for `/admin/operations/{run}`
- [X] T006 [P] [US1] Extend `tests/Feature/RunAuthorizationTenantIsolationTest.php` with canonical run-detail 404 deny-as-not-found and 403 capability-denial assertions
### Implementation for User Story 1
- [X] T007 [US1] Refine `app/Policies/OperationRunPolicy.php` so canonical run authorization remains run-first, workspace-scoped, and directly tenant-entitlement-based with no remembered-context coupling
- [X] T008 [US1] Update `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` so `mount()` and any derived state helpers preserve canonical run validity independently of selected tenant context
- [X] T009 [US1] Run focused US1 verification in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/RunAuthorizationTenantIsolationTest.php`
**Checkpoint**: Canonical run legitimacy is independent of remembered tenant context and still preserves 404 vs 403 authorization semantics.
---
## Phase 4: User Story 2 - Trust Deep Links From Other Surfaces (Priority: P1)
**Goal**: Keep `View run` deep links canonical and self-sufficient from tenant pages, monitoring surfaces, notification-style entry points, and verification surfaces.
**Independent Test**: Open canonical run links generated from a tenant page, a notification-style entry point, and a workspace verification or monitoring surface while changing or clearing the current header tenant, and verify each link still resolves to the correct run viewer.
### Tests for User Story 2
- [X] T010 [P] [US2] Add tenant-page, notification-style, and verification-surface deep-link trust coverage to `tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`
- [X] T011 [P] [US2] Extend `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` for canonical run-route continuity under changed or missing header tenant context across monitoring and verification surfaces
### Implementation for User Story 2
- [X] T012 [US2] Keep canonical run-link generation normalized through `app/Support/OperationRunLinks.php` and update any touched canonical-route or lifecycle-safe follow-up expectations in `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
- [X] T013 [US2] Update `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` so canonical navigation context and header or follow-up actions remain self-sufficient and lifecycle-safe after deep-link entry regardless of prior tenant context
- [X] T014 [US2] Run focused US2 verification in `tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
**Checkpoint**: Canonical run links remain dependable from tenant pages, notifications, verification surfaces, and monitoring surfaces.
---
## Phase 5: User Story 3 - Understand Context And Lifecycle Without Being Blocked (Priority: P2)
**Goal**: Show non-blocking context and lifecycle messaging so operators can distinguish mismatch, tenantless, onboarding, archived, and selector-excluded states without mistaking them for access failure or over-trusting follow-up actions.
**Independent Test**: Open canonical runs for matching tenant, mismatched tenant, tenantless, onboarding-tenant, archived-tenant, and selector-excluded scenarios, and verify the page stays viewable with the correct informational banner or framing plus lifecycle-safe follow-up action treatment.
### Tests for User Story 3
- [X] T015 [P] [US3] Add mismatch, tenantless, onboarding, archived, and selector-excluded banner assertions to `tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php`
- [X] T016 [P] [US3] Extend `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php` for non-blocking context, lifecycle presentation, and lifecycle-safe follow-up affordances
### Implementation for User Story 3
- [X] T017 [US3] Add derived canonical viewer banner and follow-up-affordance state in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` for run tenant, current header tenant, tenantless, and selector-excluded lifecycle scenarios
- [X] T018 [US3] Render the non-blocking canonical context and lifecycle banner in `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` above the existing redaction note and infolist, with reduced follow-up actions handled safely when unavailable
- [X] T019 [US3] Adjust display wording or fallback handling in `app/Support/OperateHub/OperateHubShell.php` only as needed to keep banner, return-affordance, and reduced follow-up-action language aligned with remembered-context semantics
- [X] T020 [US3] Run focused US3 verification in `tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, and `tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php`
**Checkpoint**: Canonical run viewing stays accessible and explains mismatch and lifecycle state clearly without blocking the page or overstating available follow-up actions.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Preserve convenience-only operations-index behavior, run the full focused regression pack, and finalize formatting.
- [X] T021 [P] Validate convenience-only tenant-prefilter behavior in `tests/Feature/Monitoring/OperationsTenantScopeTest.php` after viewer hardening changes
- [X] T022 [P] Re-run shared canonical-context regressions in `tests/Feature/OpsUx/OperateHubShellTest.php` and `tests/Feature/Monitoring/HeaderContextBarTest.php`, confirming the canonical-viewer rule remains reusable beyond operation-run-specific wording
- [X] T023 Format touched files, including `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `app/Policies/OperationRunPolicy.php`, `app/Support/OperateHub/OperateHubShell.php`, `app/Support/Middleware/EnsureFilamentTenantSelected.php`, `app/Support/OperationRunLinks.php`, and `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`, with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T024 Run the full focused verification command documented in `specs/144-canonical-operation-viewer-context-decoupling/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; start immediately.
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
- **User Story 1 (Phase 3)**: Starts after Foundational; establishes the MVP legitimacy boundary.
- **User Story 2 (Phase 4)**: Starts after Foundational; final verification is safer after User Story 1 because deep-link trust depends on canonical viewer legitimacy being correct.
- **User Story 3 (Phase 5)**: Starts after User Story 1 because it layers UX messaging on the validated canonical viewer semantics.
- **Polish (Phase 6)**: Depends on the desired user stories being complete.
### User Story Dependencies
- **US1**: No dependency on other stories after Foundational; this is the MVP slice.
- **US2**: Can begin after Foundational, but should merge after US1 to avoid deep-link tests masking viewer-legitimacy issues.
- **US3**: Depends on US1's run-validity behavior and should be applied after the canonical viewer semantics are stable.
### Within Each User Story
- Tests must be written or updated first and must fail before implementation.
- Authorization boundary changes come before presentation changes.
- Viewer logic changes come before Blade wrapper changes.
- Focused test runs complete each story before the next story is closed.
### Parallel Opportunities
- T002 and T003 can run in parallel.
- T005 and T006 can run in parallel.
- T010 and T011 can run in parallel.
- T015 and T016 can run in parallel.
- T021 and T022 can run in parallel.
---
## Parallel Example: User Story 1
```bash
# Launch the US1 regression updates together:
Task: "Extend tests/Feature/Operations/TenantlessOperationRunViewerTest.php with mismatched-header-tenant and tenantless-run coverage"
Task: "Extend tests/Feature/RunAuthorizationTenantIsolationTest.php with canonical run-detail 404 and 403 assertions"
```
## Parallel Example: User Story 2
```bash
# Launch the US2 deep-link coverage updates together:
Task: "Add tenant-page, notification-style, and verification-surface deep-link trust coverage to tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php"
Task: "Extend tests/Feature/OpsUx/CanonicalViewRunLinksTest.php and tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php for canonical route continuity"
```
## Parallel Example: User Story 3
```bash
# Launch the US3 messaging coverage updates together:
Task: "Add mismatch, tenantless, onboarding, archived, and selector-excluded banner assertions to tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php"
Task: "Extend tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php and tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php"
```
---
## Implementation Strategy
### MVP First
Deliver **User Story 1** first. That gives the repo the core guarantee that canonical run validity is no longer coupled to remembered tenant context.
### Incremental Delivery
1. Complete Setup and Foundational work.
2. Deliver US1 and validate canonical 404 vs 403 semantics.
3. Deliver US2 to prove deep-link trust across source surfaces.
4. Deliver US3 to add transparent, non-blocking context and lifecycle messaging.
5. Finish with the cross-cutting regression and formatting sweep.
### Validation Standard
No phase is complete until its focused Pest files pass. The full focused command in `specs/144-canonical-operation-viewer-context-decoupling/quickstart.md` is the final acceptance gate before implementation is considered ready for review.

View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class CanonicalOperationViewerContextMismatchTest extends TestCase
{
use RefreshDatabase;
public function test_shows_non_blocking_mismatch_context_when_the_selected_tenant_differs_from_the_run_tenant(): void
{
$runTenant = Tenant::factory()->create([
'name' => 'Run Tenant',
'workspace_id' => null,
]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
$currentTenant = Tenant::factory()->create([
'name' => 'Current Tenant',
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
'tenant_id' => (int) $runTenant->getKey(),
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant($currentTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Current tenant context differs from this run')
->assertSee('Current tenant context: Current Tenant.')
->assertSee('Run tenant: Run Tenant.')
->assertSee('canonical workspace view');
}
public function test_frames_tenantless_runs_as_workspace_level_even_when_tenant_context_is_selected(): void
{
$selectedTenant = Tenant::factory()->create([
'name' => 'Selected Tenant',
]);
[$user, $selectedTenant] = createUserWithTenant(tenant: $selectedTenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $selectedTenant->workspace_id,
'tenant_id' => null,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant($selectedTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Workspace-level run')
->assertSee('This canonical workspace view is not tied to the current tenant context (Selected Tenant).');
}
public function test_keeps_onboarding_tenant_runs_viewable_with_lifecycle_aware_context(): void
{
$tenant = Tenant::factory()->create([
'name' => 'Onboarding Tenant',
'status' => Tenant::STATUS_ONBOARDING,
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('Run tenant: Onboarding Tenant.')
->assertSee('This tenant is currently onboarding')
->assertSee('Back to Operations')
->assertDontSee('← Back to Onboarding Tenant');
}
public function test_keeps_archived_tenant_runs_viewable_with_lifecycle_aware_context(): void
{
$activeTenant = Tenant::factory()->create([
'name' => 'Active Tenant',
]);
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
$archivedTenant = Tenant::factory()->create([
'name' => 'Archived Tenant',
'workspace_id' => (int) $activeTenant->workspace_id,
'status' => Tenant::STATUS_ACTIVE,
]);
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner');
$archivedTenant->delete();
$run = OperationRun::factory()->create([
'workspace_id' => (int) $activeTenant->workspace_id,
'tenant_id' => (int) $archivedTenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('Run tenant: Archived Tenant.')
->assertSee('This tenant is currently archived')
->assertSee('Back to Operations')
->assertDontSee('← Back to Archived Tenant');
}
public function test_keeps_selector_excluded_draft_tenant_runs_viewable_with_lifecycle_aware_context(): void
{
$tenant = Tenant::factory()->create([
'name' => 'Draft Tenant',
'status' => Tenant::STATUS_DRAFT,
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('Run tenant: Draft Tenant.')
->assertSee('This tenant is currently draft');
}
}

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class CanonicalOperationViewerDeepLinkTrustTest extends TestCase
{
use RefreshDatabase;
public function test_trusts_canonical_run_links_opened_from_a_tenant_surface_after_the_header_tenant_changes(): void
{
$runTenant = Tenant::factory()->create([
'name' => 'Tenant Surface',
]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
$otherTenant = Tenant::factory()->create([
'name' => 'Other Tenant',
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
'tenant_id' => (int) $runTenant->getKey(),
'type' => 'policy.sync',
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'tenant.detail',
canonicalRouteName: 'admin.operations.view',
tenantId: (int) $runTenant->getKey(),
backLinkLabel: 'Back to tenant',
backLinkUrl: route('filament.admin.resources.tenants.view', ['record' => $runTenant]),
);
Filament::setTenant($otherTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
->get(OperationRunLinks::view($run, $runTenant, $context))
->assertOk()
->assertSee('Back to tenant')
->assertSee(route('filament.admin.resources.tenants.view', ['record' => $runTenant]), false)
->assertSee('Current tenant context differs from this run');
}
public function test_trusts_notification_style_run_links_with_no_selected_tenant_context(): void
{
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Operation run')
->assertSee('Canonical workspace view');
}
public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void
{
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'context' => [
'verification_report' => json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
JSON_THROW_ON_ERROR,
),
],
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'verification.report',
canonicalRouteName: 'admin.operations.view',
tenantId: (int) $tenant->getKey(),
backLinkLabel: 'Back to verification',
backLinkUrl: '/admin/verification/report',
);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run, $context))
->assertOk()
->assertSee('Verification report')
->assertSee('Back to verification')
->assertSee('/admin/verification/report', false);
}
}

View File

@ -4,6 +4,7 @@
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -110,6 +111,47 @@ function visiblePageText(TestResponse $response): string
->assertSee('/admin/t/'.$tenant->external_id.'/backup-sets/'.$backupSet->getKey(), false);
});
it('renders mismatch context above the enterprise detail content without blocking the page', function (): void {
$runTenant = Tenant::factory()->create([
'name' => 'Run Tenant',
]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
$currentTenant = Tenant::factory()->create([
'name' => 'Current Tenant',
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner');
Filament::setTenant($currentTenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
'tenant_id' => (int) $runTenant->getKey(),
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Current tenant context differs from this run')
->assertSee('Run summary')
->assertSee('Related context');
$pageText = visiblePageText($response);
$bannerPosition = mb_strpos($pageText, 'Current tenant context differs from this run');
$summaryPosition = mb_strpos($pageText, 'Run summary');
expect($bannerPosition)->not->toBeFalse()
->and($summaryPosition)->not->toBeFalse()
->and($bannerPosition)->toBeLessThan($summaryPosition);
});
it('renders explicit sparse-data fallbacks for operation runs', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -217,6 +217,48 @@
->assertSet('data.override_reason', '');
});
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 999999,
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->assertWizardCurrentStep(2)
->assertSet('selectedProviderConnectionId', null)
->assertSet('data.provider_connection_id', null);
});
it('allows workspace owners to create a dedicated override connection explicitly during onboarding', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -242,3 +242,60 @@
->assertSee('TenantA')
->assertSee('TenantB');
});
it('shows explicit mismatch context on canonical run pages while keeping the current header tenant label', function (): void {
$runTenant = Tenant::factory()->create([
'name' => 'Run Tenant',
]);
[$user, $runTenant] = createUserWithTenant($runTenant, role: 'owner');
$currentTenant = Tenant::factory()->create([
'name' => 'Current Tenant',
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant($currentTenant, $user, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $runTenant->getKey(),
'workspace_id' => (int) $runTenant->workspace_id,
'type' => 'policy.sync',
]);
Filament::setTenant($currentTenant, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Tenant scope: Current Tenant')
->assertSee('Current tenant context differs from this run')
->assertSee('Run tenant: Run Tenant.');
});
it('shows canonical workspace framing on canonical run pages with no selected tenant context', function (): void {
$tenant = Tenant::factory()->create([
'name' => 'Workspace Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('All tenants')
->assertSee('Canonical workspace view')
->assertSee('No tenant context is currently selected.');
});

View File

@ -4,7 +4,10 @@
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('renders operation run related context with backup set details', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -27,3 +30,34 @@
->assertSee('Nightly backup')
->assertSee('Backup set #'.$backupSet->getKey());
});
it('keeps archived run references viewable with lifecycle-aware framing', function (): void {
$activeTenant = Tenant::factory()->create([
'name' => 'Active Tenant',
]);
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
$this->actingAs($user);
$archivedTenant = Tenant::factory()->create([
'name' => 'Archived Tenant',
'workspace_id' => (int) $activeTenant->workspace_id,
]);
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner');
$archivedTenant->delete();
$run = OperationRun::factory()->for($archivedTenant)->create([
'workspace_id' => (int) $activeTenant->workspace_id,
'type' => 'policy.sync',
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('This tenant is currently archived')
->assertSee('Back to Operations')
->assertDontSee('← Back to Archived Tenant');
});

View File

@ -107,6 +107,28 @@
->assertSee('Operation run');
});
it('keeps canonical operation detail accessible for selector-excluded tenant runs', function (): void {
$tenant = Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING,
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('This tenant is currently onboarding');
});
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');

View File

@ -24,7 +24,7 @@
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::factory()->create([
$runA = OperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
@ -32,7 +32,7 @@
'initiator_name' => 'TenantA',
]);
OperationRun::factory()->create([
$runB = OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory_sync',
'status' => 'queued',
@ -42,14 +42,21 @@
Filament::setTenant($tenantA, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]);
Livewire::actingAs($user)
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB])
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Policy sync')
->assertSee('TenantA')
->assertDontSee('Inventory sync')
->assertDontSee('TenantB');
->assertSee('Tenant scope: '.$tenantA->name);
});
it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () {
@ -64,7 +71,7 @@
$tenantB->getKey() => ['role' => 'owner'],
]);
OperationRun::factory()->create([
$runA = OperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
@ -72,7 +79,7 @@
'initiator_name' => 'TenantA',
]);
OperationRun::factory()->create([
$runB = OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory_sync',
'status' => 'queued',
@ -85,14 +92,21 @@
$workspaceId = (int) $tenantA->workspace_id;
app(WorkspaceContext::class)->rememberLastTenantId($workspaceId, (int) $tenantA->getKey());
$this->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]);
session([WorkspaceContext::SESSION_KEY => $workspaceId]);
Livewire::actingAs($user)
->test(Operations::class)
->assertCanSeeTableRecords([$runA])
->assertCanNotSeeTableRecords([$runB])
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId])
->get('/admin/operations')
->assertOk()
->assertSee('Tenant scope: '.$tenantA->name)
->assertSee('Policy sync')
->assertSee('TenantA')
->assertDontSee('Inventory sync');
->assertSee('Policy sync');
});
it('scopes Monitoring → Operations tabs to the active tenant', function () {

View File

@ -148,6 +148,33 @@
->assertSee('Back to Operations');
});
it('keeps tenantless run viewing accessible while another tenant is selected', function (): void {
$selectedTenant = Tenant::factory()->create();
[$user, $selectedTenant] = createUserWithTenant(tenant: $selectedTenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $selectedTenant->workspace_id,
'tenant_id' => null,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant($selectedTenant, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $selectedTenant->workspace_id => (int) $selectedTenant->getKey(),
],
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful()
->assertSee('Workspace-level run')
->assertSee('This canonical workspace view is not tied to the current tenant context');
});
it('renders stored target scope and failure details for a completed run', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use Illuminate\Support\Facades\File;
@ -41,3 +42,13 @@
expect(OperationRunLinks::tenantlessView($run))->toBe($expectedUrl);
expect(OperationRunLinks::tenantlessView((int) $run->getKey()))->toBe($expectedUrl);
})->group('ops-ux');
it('normalizes tenant-scoped callers onto the canonical tenantless run route', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->for($tenant)->create();
$expectedUrl = route('admin.operations.view', ['run' => (int) $run->getKey()]);
expect(OperationRunLinks::view($run, $tenant))->toBe($expectedUrl)
->and(OperationRunLinks::view((int) $run->getKey(), $tenant))->toBe($expectedUrl);
})->group('ops-ux');

View File

@ -254,6 +254,45 @@
expect($resolved?->is($tenantB))->toBeTrue();
})->group('ops-ux');
it('prefers the current filament tenant over remembered tenant state on canonical run routes', function (): void {
$runTenant = Tenant::factory()->create([
'workspace_id' => null,
]);
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
$currentTenant = Tenant::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
'tenant_id' => (int) $runTenant->getKey(),
'type' => 'policy.sync',
]);
$this->actingAs($user);
Filament::setTenant($currentTenant, true);
$workspaceId = (int) $runTenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $runTenant->getKey(),
]);
$request = Request::create(route('admin.operations.view', ['run' => (int) $run->getKey()]));
$request->setLaravelSession(app('session.store'));
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->activeEntitledTenant($request);
expect($resolved?->is($currentTenant))->toBeTrue();
})->group('ops-ux');
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -185,3 +185,24 @@
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});
test('canonical run detail returns 403 for in-scope members missing the required capability', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertForbidden();
});

View File

@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Support\Onboarding\OnboardingDraftStage;
use App\Support\OperationRunOutcome;
@ -35,8 +37,30 @@
});
it('derives the verify access stage when a provider connection is selected but verification is incomplete', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'current_step' => 'connection',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
expect(app(OnboardingDraftStageResolver::class)->resolve($draft))
->toBe(OnboardingDraftStage::VerifyAccess);
});
it('derives the connect provider stage when the persisted provider connection no longer exists', function (): void {
$draft = createOnboardingDraft([
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
'tenant_name' => 'Contoso',
@ -45,25 +69,34 @@
]);
expect(app(OnboardingDraftStageResolver::class)->resolve($draft))
->toBe(OnboardingDraftStage::VerifyAccess);
->toBe(OnboardingDraftStage::ConnectProvider);
});
it('derives the review stage when verification completed for the selected provider connection', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
'tenant_name' => 'Contoso',
'provider_connection_id' => 84,
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $draft->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => 84,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -78,22 +111,31 @@
});
it('derives the bootstrap stage when bootstrap choices were already selected but not completed', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'current_step' => 'bootstrap',
'state' => [
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
'tenant_name' => 'Contoso',
'provider_connection_id' => 126,
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'bootstrap_operation_types' => ['inventory_sync'],
],
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $draft->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => 126,
'provider_connection_id' => (int) $connection->getKey(),
],
]);

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Support\Onboarding\OnboardingCheckpoint;
@ -15,6 +16,10 @@
it('marks a draft as action required when the provider connection changed before verification reruns', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
@ -23,7 +28,7 @@
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 42,
'provider_connection_id' => (int) $connection->getKey(),
'connection_recently_updated' => true,
],
]);
@ -37,7 +42,7 @@
->and($snapshot['blocking_reason_code'])->toBe('provider_connection_changed');
});
it('marks a draft as ready for activation when verification succeeded for the selected connection', function (): void {
it('treats a missing persisted provider connection as connect-provider state instead of verify state', function (): void {
$tenant = Tenant::factory()->create();
$draft = createOnboardingDraft([
@ -47,7 +52,34 @@
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 84,
'provider_connection_id' => 42,
],
]);
$snapshot = app(OnboardingLifecycleService::class)->snapshot($draft);
expect($snapshot['lifecycle_state'])->toBe(OnboardingLifecycleState::Draft)
->and($snapshot['current_checkpoint'])->toBe(OnboardingCheckpoint::ConnectProvider)
->and($snapshot['last_completed_checkpoint'])->toBe(OnboardingCheckpoint::Identify)
->and($snapshot['reason_code'])->toBeNull()
->and($snapshot['blocking_reason_code'])->toBeNull();
});
it('marks a draft as ready for activation when verification succeeded for the selected connection', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -57,7 +89,7 @@
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => 84,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -78,6 +110,10 @@
it('marks a draft as bootstrapping while a selected bootstrap run is still active', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$verificationRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
@ -85,7 +121,7 @@
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => 126,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -96,7 +132,7 @@
'outcome' => OperationRunOutcome::Pending->value,
'type' => 'inventory_sync',
'context' => [
'provider_connection_id' => 126,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -107,7 +143,7 @@
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 126,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
'bootstrap_operation_types' => ['inventory_sync'],
'bootstrap_operation_runs' => [
@ -127,6 +163,10 @@
it('marks a draft as action required when a selected bootstrap run fails', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$verificationRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
@ -134,7 +174,7 @@
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => 256,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -145,7 +185,7 @@
'outcome' => OperationRunOutcome::Failed->value,
'type' => 'inventory_sync',
'context' => [
'provider_connection_id' => 256,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
@ -156,7 +196,7 @@
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 256,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
'bootstrap_operation_types' => ['inventory_sync'],
'bootstrap_operation_runs' => [
@ -176,6 +216,10 @@
it('applies the canonical lifecycle fields and normalizes the version floor', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
@ -188,7 +232,7 @@
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => 999,
'provider_connection_id' => (int) $connection->getKey(),
'connection_recently_updated' => true,
],
]);