TenantAtlas/specs/251-commercial-entitlements-billing-state/research.md
ahmido 7ee4909212
Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
feat: commercial lifecycle overlay for workspace entitlements (#292)
## Summary
- add the bounded workspace commercial lifecycle overlay from spec 251 on top of the existing entitlement substrate
- expose audited commercial state inspection and mutation on the system workspace detail surface
- gate onboarding activation and review-pack start actions through the shared lifecycle decision while preserving suspended read-only access to existing review, evidence, and generated-pack history
- add focused Pest coverage plus the spec/plan/tasks/data-model/contract artifacts for the feature

## Validation
- targeted Pest unit and feature lanes for lifecycle resolution, system-plane mutation, onboarding gating, review-pack enforcement, download preservation, customer review workspace access, and evidence snapshot access
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated browser smoke on the system workspace detail and the preserved read-only review/evidence/review-pack surfaces

## Notes
- branch: `251-commercial-entitlements-billing-state`
- base: `dev`
- commit: `606e9760`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #292
2026-04-28 13:39:33 +00:00

8.6 KiB

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.