Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
## 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
8.6 KiB
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
WorkspaceSettingkeys in the existingentitlementsdomain, conceptuallycommercial_lifecycle_statepluscommercial_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, orcustomer_accountstables: rejected because the spec explicitly forbids broad billing/account scope. - A separate
commercialsettings domain: rejected because the new state is an overlay on the already-real entitlement substrate, not a second independent settings family.
- New
Decision 2: Add one bounded lifecycle overlay service above WorkspaceEntitlementResolver
- Decision: Introduce one bounded
WorkspaceCommercialLifecycleResolverinApp\Services\Entitlementsthat composesWorkspaceEntitlementResolverinstead 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
WorkspaceEntitlementResolveruntil 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.
- Extend
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-protectedChange commercial stateaction. - 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.
- Add lifecycle mutation to
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
ReviewPackServiceas the hard enforcement boundary and block lifecycle-restricted starts before anyReviewPackorOperationRunwrite 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
WorkspaceEntitlementBlockedExceptionand extending its decision payload for lifecycle blocks, rather than introducing a second parallel business-state exception family. - Rationale: Review-pack widgets/resources already catch
WorkspaceEntitlementBlockedExceptionand project itsblock_reasoninto 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.
- New
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, andsuspended_read_only. - Rationale: The spec requires these named postures, and platform operators need to set and audit them explicitly from one system surface.
graceandsuspended_read_onlyhave immediate distinct action-family consequences.trialremains 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_suspendedandis_in_grace: rejected because that would not yield one clear operator-facing commercial state.
- Collapse to three states by removing
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
/systemdetail already anchors the platform workflow. - Asset-backed custom commercial UI: rejected because current Filament components and the existing Blade detail view are sufficient.
- New commercial panel: rejected because