TenantAtlas/specs/251-commercial-entitlements-billing-state/research.md
Ahmed Darrazi 606e9760dd
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m45s
feat: implement workspace commercial lifecycle overlay
2026-04-28 15:29:50 +02:00

84 lines
8.6 KiB
Markdown

# Research: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
## Decision 1: Persist lifecycle truth inside the existing `entitlements` settings domain
- **Decision**: Store the workspace commercial lifecycle overlay through explicit `WorkspaceSetting` keys in the existing `entitlements` domain, conceptually `commercial_lifecycle_state` plus `commercial_lifecycle_reason`.
- **Rationale**: Spec 247 already proved that workspace-owned commercial truth belongs in the existing workspace settings infrastructure. Reusing that path keeps audit behavior, validation, and source-of-truth ownership consistent without inventing a billing/account model or a second persistence family.
- **Alternatives considered**:
- New `subscriptions`, `billing_states`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope.
- A separate `commercial` settings domain: rejected because the new state is an overlay on the already-real entitlement substrate, not a second independent settings family.
## Decision 2: Add one bounded lifecycle overlay service above `WorkspaceEntitlementResolver`
- **Decision**: Introduce one bounded `WorkspaceCommercialLifecycleResolver` in `App\Services\Entitlements` that composes `WorkspaceEntitlementResolver` instead of replacing it.
- **Rationale**: The underlying entitlement resolver remains canonical for plan-profile defaults, override values, and per-key allow/block truth. The new feature needs one additional workspace-wide layer that can answer lifecycle state, lifecycle rationale, and action-family outcomes across onboarding, review-pack starts, and preserved read-only history access.
- **Alternatives considered**:
- Extend `WorkspaceEntitlementResolver` until it also owns lifecycle posture: rejected because that would blur substrate truth with the new overlay and make future review of state ordering harder.
- Local page/service conditionals in onboarding, review-pack resources, and system detail: rejected because they would drift immediately.
## Decision 3: Keep system-plane mutation on the existing workspace detail page only
- **Decision**: Make `/system/directory/workspaces/{workspace}` the only mutation surface for lifecycle state changes, with inspection plus a confirmation-protected `Change commercial state` action.
- **Rationale**: The spec requires platform-managed lifecycle mutation. The existing system workspace detail page already exposes commercial truth read-only and is the narrowest platform context that can show state, rationale, and audit attribution without creating a second control plane.
- **Alternatives considered**:
- Add lifecycle mutation to `/admin/settings/workspace`: rejected because the slice must not become a self-service workspace-admin commercial control surface.
- Create a dedicated system commercial page/resource: rejected because the existing workspace detail page already anchors the platform/support workflow.
## Decision 4: Preserve explicit business-state versus authorization semantics
- **Decision**: Keep non-member and wrong-plane access as 404, keep established-scope capability denial as 403, and treat lifecycle blocking or warnings as business-state results for otherwise authorized actors.
- **Rationale**: This is the main operator value of the slice. The commercial lifecycle overlay must explain why an action is blocked without pretending the actor lacks scope or permission.
- **Alternatives considered**:
- Hide blocked actions entirely: rejected because it would erase the commercial explanation the feature exists to provide.
- Return 403 for lifecycle blocks: rejected because it would conflate business state with authorization.
## Decision 5: Review-pack lifecycle blocking must happen before `ReviewPack` or `OperationRun` creation
- **Decision**: Reuse `ReviewPackService` as the hard enforcement boundary and block lifecycle-restricted starts before any `ReviewPack` or `OperationRun` write occurs.
- **Rationale**: Current review-pack start surfaces already converge on `ReviewPackService`. Blocking at the service boundary prevents UI-surface bypass and preserves the shared OperationRun start UX for allowed actions.
- **Alternatives considered**:
- UI-only disabling on each widget/resource/page action: rejected because it would not protect direct action execution.
- A new review-pack lifecycle queue/framework: rejected because the slice changes eligibility only, not run orchestration.
## Decision 6: Reuse the existing blocked-decision transport if it can carry lifecycle metadata cleanly
- **Decision**: Prefer reusing `WorkspaceEntitlementBlockedException` and extending its decision payload for lifecycle blocks, rather than introducing a second parallel business-state exception family.
- **Rationale**: Review-pack widgets/resources already catch `WorkspaceEntitlementBlockedException` and project its `block_reason` into user-visible feedback. Reusing that transport keeps the change narrow unless implementation proves the class name or payload shape is too substrate-specific.
- **Alternatives considered**:
- New `WorkspaceCommercialLifecycleBlockedException`: rejected for now because it would widen changes across all review-pack action surfaces without proving extra value.
- Plain string returns without a shared decision payload: rejected because the UI surfaces already consume structured block context.
## Decision 7: Preserve suspended read-only access by leaving existing history/evidence/download routes outside the new gate
- **Decision**: Keep `CustomerReviewWorkspace`, `ViewTenantReview`, `ViewReviewPack`, `ViewEvidenceSnapshot`, and current review-pack download access outside the new lifecycle start gate, while allowing them to show a calm read-only explanation when helpful.
- **Rationale**: The feature promise is not "suspend everything." It is "block future starts while preserving safe existing history." Existing view/download routes already encode current RBAC and redaction semantics and are the narrowest place to preserve that truth.
- **Alternatives considered**:
- Broad product-wide suspension of all mutable controls: rejected because the spec explicitly forbids a broad suspension engine.
- No plan for preserved read access: rejected because suspension would otherwise appear as total lockout and break the evidence/history requirement.
## Decision 8: Keep the four-state vocabulary, but justify it narrowly
- **Decision**: Keep exactly four lifecycle states: `trial`, `grace`, `active_paid`, and `suspended_read_only`.
- **Rationale**: The spec requires these named postures, and platform operators need to set and audit them explicitly from one system surface. `grace` and `suspended_read_only` have immediate distinct action-family consequences. `trial` remains in scope because the platform/support workflow and audit trail need to distinguish temporary non-paid posture from steady active paid posture now, even though both allow the two in-scope gated behavior families.
- **Alternatives considered**:
- Collapse to three states by removing `trial`: rejected because it would erase a required current-release commercial posture and force later renaming/migration when trial lifecycle work grows.
- Persist only booleans like `is_suspended` and `is_in_grace`: rejected because that would not yield one clear operator-facing commercial state.
## Decision 9: Prove the slice with focused unit and feature lanes only
- **Decision**: Use one unit family for lifecycle resolution and focused feature tests for system mutation, onboarding gating, review-pack no-run blocking, and suspended read-only consumption.
- **Rationale**: The primary risk is correctness of decision ordering and bounded surface behavior, not browser layout or heavy orchestration.
- **Alternatives considered**:
- Browser tests: rejected because no browser-only interaction risk is introduced in the planning slice.
- Heavy-governance suite expansion: rejected because the scope is feature-local and uses existing surfaces.
## Decision 10: Leave panels, assets, and global search unchanged
- **Decision**: Do not add new panels, provider registration changes, global-search resources, or Filament assets as part of this slice.
- **Rationale**: The feature is a business-state overlay inside existing admin and system surfaces. Infrastructure changes would widen scope without helping the current release.
- **Alternatives considered**:
- New commercial panel: rejected because `/system` detail already anchors the platform workflow.
- Asset-backed custom commercial UI: rejected because current Filament components and the existing Blade detail view are sufficient.