TenantAtlas/specs/148-central-tenant-operability-policy/research.md
ahmido 417df4f9aa feat: central tenant operability policy (#177)
## Summary
- centralize tenant operability into a lane-aware, actor-aware policy boundary
- align selector eligibility, administrative discoverability, remembered context, tenant-bound routes, and canonical run viewers
- add focused Pest coverage plus Spec 148 artifacts and final polish task completion

## Validation
- `vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantOperabilityServiceTest.php tests/Unit/Tenants/TenantOperabilityOutcomeTest.php tests/Feature/Workspaces/ChooseTenantPageTest.php tests/Feature/Workspaces/SelectTenantControllerTest.php tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php tests/Feature/Rbac/TenantResourceAuthorizationTest.php tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`
- manual browser smoke checks on `/admin/choose-tenant`, `/admin/tenants`, `/admin/onboarding`, `/admin/onboarding/{draft}`, and `/admin/operations/{run}`

## Filament / platform notes
- Livewire v4 compliance preserved
- panel provider registration unchanged in `bootstrap/providers.php`
- Tenant resource global search remains backed by existing view/edit pages and is now separated from active-only selector eligibility
- destructive actions remain action closures with confirmation and authorization enforcement
- no asset pipeline changes and no new `filament:assets` deployment requirement

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #177
2026-03-17 11:48:55 +00:00

6.3 KiB
Raw Blame History

Phase 0 Research: Central Tenant Operability Policy

Decision: Extend the existing tenant operability seam instead of creating a second policy subsystem

Rationale: The repo already has TenantOperabilityService, TenantOperabilityDecision, TenantActionPolicySurface, TenantPageCategory, and OperateHubShell. The problem is not lack of abstraction but that the current abstraction is tenant-only and boolean-only. Extending that seam keeps the implementation aligned with the current support-layer architecture and avoids another round of scattered semantics.

Alternatives considered:

  • Create a brand-new TenantOperabilityPolicy tree and deprecate the current service immediately: rejected because it would duplicate existing support-layer concepts and create avoidable migration churn.
  • Push all behavior into Laravel policies only: rejected because the feature needs lane-aware visibility and selector semantics in addition to authorization outcomes.

Decision: Make operability evaluation explicit, actor-aware, and lane-aware

Rationale: Current TenantOperabilityService::decisionFor() only receives a Tenant and derives booleans from lifecycle plus soft-delete state. Spec 148 requires decisions that also depend on actor, workspace membership, tenant entitlement, route type, onboarding workflow context, and linked record context. That requires an explicit input object or equivalent structured arguments rather than ambient request state.

Alternatives considered:

  • Continue using request-path inference and auth globals only: rejected because the feature explicitly requires reusable policy evaluation across selectors, pages, controllers, middleware, and future governance surfaces.
  • Add more helper methods like canViewForOperations() and canViewForOnboarding(): rejected because method proliferation would keep the same semantic sprawl, only in one class.

Decision: Return structured outcomes with reason codes, not only booleans

Rationale: The spec requires reasoned denials and clearer UX. Existing TenantOperabilityDecision supports booleans only, which is sufficient for simple visibility but not for distinguishing lifecycle mismatch, wrong lane, missing capability, selector ineligibility, or canonical-record follow-up limits. A structured outcome enables consistent UI messaging, more precise test assertions, and future audit or diagnostics.

Alternatives considered:

  • Keep booleans and let each surface derive its own explanations: rejected because that recreates local branching and undermines same-question consistency.
  • Use free-form reason strings: rejected because stable reason codes are more testable and less likely to drift.

Decision: Separate selector eligibility from administrative discoverability

Rationale: The code already shows the risk of conflating these concepts. ChooseTenant correctly filters selectable tenants via TenantOperabilityService, but TenantResource::getGlobalSearchEloquentQuery() also uses applySelectableScope(), which means active-selector eligibility is already affecting discoverability. Spec 148 requires selector eligibility, remembered-context validity, administrative discoverability, and canonical reference handling to be distinct policy questions.

Alternatives considered:

  • Treat selector membership as the universal “valid tenant” rule: rejected because onboarding and archived tenants must remain visible in administrative, onboarding, and canonical record lanes.
  • Keep administrative visibility ad hoc while only centralizing selector logic: rejected because the specs goal is one authoritative tenant-semantic boundary.

Decision: Preserve route-authoritative semantics for tenant-bound and canonical viewers

Rationale: Existing code already partially supports this. Tenant::resolveRouteBinding() uses withTrashed() for tenant external IDs, TenantPageCategory distinguishes tenant-bound versus canonical pages, and TenantlessOperationRunViewer already frames selected-tenant mismatch as informational. The correct design direction is to formalize these semantics through the central operability policy rather than letting shell and page-level code make their own exceptions.

Alternatives considered:

  • Force route viewers to align with currently selected tenant context: rejected because Specs 144 and 147 already establish that selected context is not the authority for route legitimacy.
  • Make canonical viewers tenant-blind: rejected because tenant entitlement still matters for linked record access.

Decision: Keep authorization enforcement in Gates or policies and compose operability with it

Rationale: Laravel documentation confirms the expected split: use authorization responses for 404 concealment or 403 denial, and keep server-side authorization authoritative. Operability should decide whether an action makes sense in the tenants lifecycle and lane, but it must not become a vague replacement for workspace membership, tenant entitlement, or capability enforcement.

Alternatives considered:

  • Move all capability checks into the operability service: rejected because that would blur authorization boundaries and make policy outcomes harder to reason about.
  • Keep authorization entirely separate from operability with no shared contract: rejected because action surfaces and route consumers still need one semantic answer that combines both concerns intentionally.

Decision: Preserve existing Filament action safety rules and global-search requirements

Rationale: Filament documentation confirms destructive actions must use ->action(...)->requiresConfirmation() and that confirmation is not available on URL-only actions. The repo already has Archive and Restore modeled as action closures with confirmation, and TenantResource already has View or Edit pages, which keeps it eligible for global search under Filament v5. The implementation should preserve these contracts while changing only the decision authority behind visibility and eligibility.

Alternatives considered:

  • Convert lifecycle actions into URL-only redirects with local confirmation copy: rejected because Filament confirmation modals require action closures.
  • Disable global search to avoid selector or discoverability complexity: rejected because the feature needs better semantics, not narrower product behavior.