# Implementation Plan: Operations Tenantless Canonical Migration **Branch**: `078-operations-tenantless-canonical` | **Date**: 2025-07-13 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `/specs/078-operations-tenantless-canonical/spec.md` ## Summary Make Operations detail **fully canonical** at `/admin/operations/{run}` by converting `TenantlessOperationRunViewer` to reuse `OperationRunResource::infolist()` via Filament v5's unified schema system, decommissioning auto-generated tenant-scoped resource pages (they naturally 404 after route removal), cleaning up dead code, and updating all affected tests. Key approach: Filament v5 deprecated `InteractsWithInfolists` — every `Page` already has `InteractsWithSchemas`. The existing `OperationRunResource::infolist()` is `public static` with no `$this` references and already handles `Filament::getTenant()` returning null. This means `TenantlessOperationRunViewer` can define `infolist(Schema $schema)` to delegate directly, achieving full visual parity with zero code duplication. ## Technical Context **Language/Version**: PHP 8.4 (Laravel 12) **Primary Dependencies**: Filament v5, Livewire v4, Filament Infolists (schema-based) **Storage**: PostgreSQL (no new migrations — read-only model changes) **Testing**: Pest v4 (Feature tests) **Target Platform**: Web (Laravel Sail / Docker) **Project Type**: Web application (monolith) **Performance Goals**: DB-only rendering (no external calls on page load) **Constraints**: Tenantless pages must render without `Filament::getTenant()`; no new dependencies **Scale/Scope**: ~15 files modified/deleted, ~8 test files updated, 0 new migrations ## Constitution Check *GATE: All pass. No violations.* | Principle | Status | Notes | |-----------|--------|-------| | Inventory-first | N/A | No inventory changes | | Read/write separation | Pass | Feature is read-only (rendering changes only) | | Graph contract path | N/A | No Graph calls | | Deterministic capabilities | N/A | No capability changes | | RBAC-UX planes | Pass | Workspace-level auth only; non-member = 404 (RBAC-UX-002) | | RBAC-UX destructive | N/A | No destructive actions | | RBAC-UX global search | Pass | Resource has `$shouldRegisterNavigation = false`, no `$recordTitleAttribute` | | Tenant isolation | Pass | Reads workspace-scoped; no cross-tenant access | | Run observability | N/A | No new operations; monitoring pages remain DB-only | | Automation | N/A | No queued/scheduled work | | Data minimization | Pass | No new data stored | | Badge semantics | Pass | Existing `BadgeRenderer` reused via infolist — no new badge mappings | **Post-design re-check**: Same results — no constitution violations. ## Project Structure ### Documentation (this feature) ```text specs/078-operations-tenantless-canonical/ +-- spec.md # Feature specification +-- plan.md # This file +-- research.md # Phase 0: Filament v5 schema research +-- data-model.md # Phase 1: Entity & routing changes +-- quickstart.md # Phase 1: Verification steps +-- contracts/ | +-- routes.md # Route contract (before/after) +-- checklists/ | +-- requirements.md # Spec quality checklist +-- tasks.md # Phase 2 output (created by /speckit.tasks) ``` ### Source Code (files touched) ```text app/ +-- Filament/ | +-- Resources/ | | +-- OperationRunResource.php # getPages() returns [] | | +-- OperationRunResource/Pages/ | | +-- ViewOperationRun.php # DELETE | | +-- ListOperationRuns.php # DELETE | +-- Pages/ | | +-- Operations/ | | +-- TenantlessOperationRunViewer.php # Infolist reuse + related links | +-- Widgets/ | +-- Operations/ | +-- OperationsKpiHeader.php # Hide stats when no tenant +-- Livewire/ +-- Monitoring/ +-- OperationsDetail.php # DELETE (dead code) resources/views/ +-- filament/pages/operations/ | +-- tenantless-operation-run-viewer.blade.php # Replace HTML with infolist render +-- livewire/monitoring/ +-- operations-detail.blade.php # DELETE (dead code) tests/Feature/ +-- Operations/ | +-- TenantlessOperationRunViewerTest.php # Update infolist assertions +-- Monitoring/ | +-- OperationsCanonicalUrlsTest.php # Remove ListOperationRuns, add route-gone | +-- OperationsTenantScopeTest.php # Remove ListOperationRuns reference +-- Verification/ | +-- VerificationAuthorizationTest.php # Canonical route instead of getUrl | +-- VerificationReportViewerDbOnlyTest.php # TenantlessViewer replaces ViewOperationRun | +-- VerificationReportRedactionTest.php # TenantlessViewer replaces ViewOperationRun | +-- VerificationReportMissingOrMalformedTest.php # TenantlessViewer replaces ViewOperationRun +-- OpsUx/ | +-- FailureSanitizationTest.php # Canonical route + TenantlessViewer | +-- CanonicalViewRunLinksTest.php # Update guard regex +-- 078/ # NEW spec-specific tests +-- CanonicalDetailRenderTest.php +-- LegacyRoutesReturnNotFoundTest.php +-- KpiHeaderTenantlessTest.php +-- VerificationReportTenantlessTest.php +-- TenantListRedirectTest.php +-- RelatedLinksOnDetailTest.php ``` **Structure Decision**: Standard Laravel monolith. No new directories except `tests/Feature/078/` for spec tests. ## Complexity Tracking > No constitution violations to justify. | Violation | Why Needed | Simpler Alternative Rejected Because | |-----------|------------|-------------------------------------| | None | N/A | N/A | --- ## Implementation Phases ### Phase A — Headless Resource + Dead Code Cleanup **Goal**: Remove auto-generated routes; delete dead code. **Risk**: Low — removing unused pages and dead code. **Tests first**: T-078-004 (routes not registered), T-078-002 (legacy URLs 404). | Step | File | Change | |------|------|--------| | A.1 | `OperationRunResource.php` | `getPages()` returns `[]` | | A.2 | `ViewOperationRun.php` | Delete file | | A.3 | `ListOperationRuns.php` | Delete file | | A.4 | `OperationsDetail.php` | Delete file (dead code) | | A.5 | `operations-detail.blade.php` | Delete file (dead code) | | A.6 | `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` | New: T-078-002 + T-078-004 | | A.7 | 7 existing test files | Replace `ViewOperationRun` / `ListOperationRuns` / `getUrl('view')` references | **Exit criteria**: All existing tests pass; legacy URLs return 404; no routes registered for resource. ### Phase B — Infolist Reuse on TenantlessOperationRunViewer **Goal**: Replace hand-coded Blade with Filament schema-based infolist for full visual parity. **Risk**: Medium — schema auto-discovery on standalone Page needs verification. **Tests first**: T-078-001 (canonical detail renders), T-078-008 (verification report tenantless). | Step | File | Change | |------|------|--------| | B.1 | `TenantlessOperationRunViewer.php` | Add `infolist(Schema $schema)` — delegates to `OperationRunResource::infolist($schema)` | | B.2 | `TenantlessOperationRunViewer.php` | Add `defaultInfolist(Schema $schema)` — `->record($this->run)->columns(2)` | | B.3 | `TenantlessOperationRunViewer.php` | Add `content(Schema $schema)` — returns `EmbeddedSchema::make('infolist')` | | B.4 | `TenantlessOperationRunViewer.php` | Add `public bool $opsUxIsTabHidden = false` property (polling callback) | | B.5 | `tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with infolist render tag | | B.6 | `tests/Feature/078/CanonicalDetailRenderTest.php` | New: T-078-001 (+ T-078-007 guard) | | B.7 | `tests/Feature/078/VerificationReportTenantlessTest.php` | New: T-078-008 | **Exit criteria**: Canonical detail shows identical layout to old tenant-scoped view; verification report section renders. ### Phase C — Contextual Navigation (Related Links) **Goal**: Replace "Admin details" button with `OperationRunLinks::related()` action group. **Risk**: Low — mechanism already exists, just wiring. **Tests first**: T-078-010 (related links appear), T-078-005 (no "Admin details" link). | Step | File | Change | |------|------|--------| | C.1 | `TenantlessOperationRunViewer.php` | Add `getHeaderActions()` using `OperationRunLinks::related()` | | C.2 | `TenantlessOperationRunViewer.php` | Remove "Admin details" button code (~line 61) | | C.3 | `tests/Feature/078/RelatedLinksOnDetailTest.php` | New: T-078-010 + T-078-005 + T-078-012 | **Exit criteria**: Related links render for different run types; "Admin details" link absent. ### Phase D — KPI Header Tenantless Handling **Goal**: Hide KPI stats when no tenant context (not workspace-scoped — deferred). **Risk**: Low — conditional early return. **Tests first**: T-078-006 (KPI hidden in tenantless mode), T-078-011 (tenantless list query safety). | Step | File | Change | |------|------|--------| | D.1 | `OperationsKpiHeader.php` | `getStats()`: if `Filament::getTenant()` is null then return `[]` | | D.2 | `tests/Feature/078/KpiHeaderTenantlessTest.php` | New: T-078-006 | | D.3 | `tests/Feature/078/OperationsListTenantlessSafetyTest.php` | New: T-078-011 | **Exit criteria**: Operations page without tenant context renders without errors; KPI section hidden. ### Phase E — List Redirect (FR-078-012) **Goal**: Convenience redirect for decommissioned list URL. **Risk**: Low — single route addition. **Tests first**: T-078-009 (302 redirect). | Step | File | Change | |------|------|--------| | E.1 | `routes/web.php` | Add redirect: `/admin/t/{tenant}/operations` 302 to `/admin/operations` | | E.2 | `tests/Feature/078/TenantListRedirectTest.php` | New: T-078-009 | **Exit criteria**: Tenant-scoped list URL redirects; no other URLs affected. --- ## Key Design Decisions ### D-001 — Schema-based infolist (not InteractsWithInfolists) Filament v5 unified forms/infolists/tables into the schema system. `InteractsWithInfolists` is deprecated. Every `Page` already has `InteractsWithSchemas` via `BasePage`. Define `infolist(Schema $schema)` on the page and it auto-discovers via reflection. **See**: [research.md](research.md) R-001 ### D-002 — Empty getPages() (not resource exclusion) Returning `[]` from `getPages()` cleanly prevents all route registration while keeping the class available for `::table()` and `::infolist()` reuse. Simpler than excluding from panel discovery. **See**: [research.md](research.md) R-003 ### D-003 — Natural 404 (not redirect handlers) After route decommission, legacy detail URLs naturally 404. No redirect handlers needed — simplest approach, zero information leakage, zero maintenance. **See**: spec.md clarifications (FR-078-005/006 removed) ### D-004 — KPI hidden (not workspace-scoped queries) Phase 1 hides KPI when no tenant. Full workspace-scoped KPI requires refactoring 6 queries + `ActiveRuns::existForWorkspace()` — deferred to separate spec. **See**: [research.md](research.md) R-004, R-005 --- ## Risk Assessment | Risk | Impact | Likelihood | Mitigation | |------|--------|------------|------------| | Schema auto-discovery fails on standalone Page | Medium | Low | Research confirms it works (R-001); fallback: static builder extraction | | Test updates miss a reference to deleted pages | Low | Medium | `grep -r` sweep for ViewOperationRun and ListOperationRuns before PR | | Infolist polling breaks without opsUxIsTabHidden | Low | Medium | Add property explicitly; existing poll logic has null-safe fallback | | KPI widget error when tenant is null | Low | Low | Already returns [] on null; this change makes it explicit | --- ## Test Strategy ### New Tests (spec-specific in tests/Feature/078/) | Test ID | File | Coverage | |---------|------|----------| | T-078-001 | CanonicalDetailRenderTest.php | Detail renders with/without tenant_id | | T-078-002 | LegacyRoutesReturnNotFoundTest.php | Legacy detail URLs return 404 | | T-078-004 | LegacyRoutesReturnNotFoundTest.php | Route names not registered | | T-078-005 | RelatedLinksOnDetailTest.php | No "Admin details" link | | T-078-006 | KpiHeaderTenantlessTest.php | KPI hidden without tenant | | T-078-008 | VerificationReportTenantlessTest.php | Verification report renders tenantless | | T-078-009 | TenantListRedirectTest.php | List redirect 302 | | T-078-010 | RelatedLinksOnDetailTest.php | Related links in header actions | | T-078-011 | OperationsListTenantlessSafetyTest.php | List renders safely with tenant and tenantless context | | T-078-012 | RelatedLinksOnDetailTest.php | Canonical CTA label is "View run" and legacy CTA removed | ### Updated Tests (existing) | File | Change | |------|--------| | VerificationAuthorizationTest.php | `getUrl('view')` replaced with `route('admin.operations.view')` | | FailureSanitizationTest.php | `getUrl('view')` replaced with canonical route; ViewOperationRun replaced with TenantlessOperationRunViewer | | CanonicalViewRunLinksTest.php | Update guard regex for headless resource | | VerificationReportViewerDbOnlyTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer | | VerificationReportRedactionTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer | | VerificationReportMissingOrMalformedTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer | | OperationsCanonicalUrlsTest.php | Remove ListOperationRuns test, add route-gone assertion | | OperationsTenantScopeTest.php | Remove ListOperationRuns reference | ### Focused Test Command ```bash vendor/bin/sail artisan test --compact \ tests/Feature/078/ \ tests/Feature/Operations/TenantlessOperationRunViewerTest.php \ tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \ tests/Feature/Monitoring/OperationsTenantScopeTest.php \ tests/Feature/Verification/VerificationAuthorizationTest.php \ tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \ tests/Feature/Verification/VerificationReportRedactionTest.php \ tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php \ tests/Feature/OpsUx/FailureSanitizationTest.php \ tests/Feature/OpsUx/CanonicalViewRunLinksTest.php ```