Skip to main content

D72 — Real per-module entitlement features: payments→features edge + the shared CoreLevelCatalog tier home (partial)

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

The first pass of the entitlements rollout (docs/tracking/feature-entitlements-rollout.md items 1+2) wires REAL features into REAL domain flows — one of each of the five feature types — alongside the still-present demo scaffolding (both coexist; features:sync realizes both; the demo teardown is a later pass once real coverage of all five types + tests exist).

Declare-from-the-owning-module. Two domain modules now contribute features the same way Team declared the boolean worked example:

  • Team adds team.seats (LIMIT; NOT-FINAL placeholder caps starter 3 / growth 10 / elite 50). A seat is consumed when an invite is accepted (the member becomes active — mirrored by the deactivate release
    • reactivate re-consume), and InviteTeamMemberCommand is gated on it so the bus refuses an invite at the cap (QUOTA_EXCEEDED) BEFORE the email is sent (consume-on-accept, gate-on-invite: the seat is the real active member; the atomic cap still blocks an accept that would overshoot). team.advanced-roles becomes a REAL check: Create/UpdateTenantRoleCommand gate on it (top tier only → FEATURE_UNAVAILABLE below elite).
  • Payments adds payments.monthly-orders (QUOTA/monthly), payments.order-fee (METER, unit order), and payments.payout-volume-fee (STEPPED, cumulative payout value) via PaymentsFeatureCatalog (NOT-FINAL placeholder magnitudes). CreateOrderCommand gates on the quota + meters one order + the per-order fee; InitiatePayoutCommand gates on the stepped fee + advances cumulative volume by the payout amount. Consumption is recorded INSIDE the handler's bus transaction (after the local row write), so a rollback also unwinds usage. This adds the payments→features dependency edge (the Team→Features precedent): payments depends on the entitlements engine; Features must not depend back on it (ModuleBoundaryTest payments block drops Modules\Features from its forbidden list, mirroring the Team allowance).

Shared tier home. The commercial tiers (starter/growth/elite) moved out of the demo catalog into Modules\Features\Support\CoreLevelCatalog, registered once from FeaturesServiceProvider — so the tiers' metadata survives the eventual demo teardown. DemoFeatureCatalog::registerLevels() delegates to it (idempotent).

Test-tenant provisioning. Because gating/metering is fail-closed on a tenant with no level/jurisdiction, the TenantFactory default is now a fully-provisioned elite/ZA tenant (the two columns only the Features resolver reads); the Features-engine + tier-gating tests override explicitly. This keeps every command flow that now touches an entitlement green without editing each test's tenant setup.

⚠️ Known production-blocking gap: that elite/ZA factory default MASKS a real gap — a self-registered tenant has NULL level/jurisdiction and no self-serve/operator path to set them (provisioning is the deferred rollout item 3), so it is hard-locked out of every gated onboarding-reachable flow. jurisdiction cannot be auto-defaulted (compliance). See the "⚠️ Known gap — production tenant provisioning" subsection in docs/tracking/feature-entitlements-rollout.md.


← Decision log index