# Entitlement Module — Technical Reference

Audience: engineers working on or integrating with the Entitlement module (`GB5Solution/Entitlement`). For a business-facing overview (purpose, plans, vision) aimed at marketing/sales/website-builders, see the companion Entitlement Overview artifact. For live project status, priorities, and open items, see the master tracker at `/Users/venkatv/.claude/plans/in-dowloads-entitlement-folder-we-woolly-stroustrup.md`.

---

## 1. What Entitlement is, architecturally

Entitlement is GB5's licensing/subscription/feature-flag control plane. It answers one question, safely and quickly, for any client/user/feature combination: **"is this feature turned on for this caller, right now?"** — and it does that resolution the same way whether the caller is an internal admin screen, another backend module, or an offline client verifying a signed bundle.

- **3-tier**: `EntitlementSL` (FastEndpoints) → `EntitlementBLL` → `EntitlementDAL`, following the standard GB5 pattern.
- **Shared platform database**: unlike most GB5 modules, Entitlement's own tables are **tenant-scoped but co-located with the platform's shared `MCLIENT`/`MUSER` tables**, not spread one-per-tenant. `MENTITLEMENT*` tables carry real FK constraints to `MCLIENT`/`MUSER`, which only works when both live in the same physical database.
- **Two audiences, two auth models**: internal GOODBOOKS_ADMIN staff (via `[MenuRights]`, the same mechanism every other GB5 admin screen uses) and external client-portal users (via a dedicated `ClientJwtBearer` JWT scheme, entirely separate from internal `MUSER`/session auth).
- **Two frontends**: `gb-ent-admin` (internal staff console, Native-Federation remote inside the `admin` project) and `gb-ent-client` (public marketing + authenticated self-service portal, a standalone Angular app with real Router routes).

---

## 2. Core concepts (quick reference)

| Concept | Table | One-line definition |
|---|---|---|
| **Feature** | `MENTITLEMENTFEATURE` | The atomic licensable capability, identified by a dotted code (e.g. `MOD.INVENTORY`); typed Boolean / Quota / Config. |
| **FeatureGroup** | `MENTITLEMENTFEATUREGROUP` | A named bucket that groups features for display (e.g. "Inventory"). |
| **Plan** | `MENTITLEMENTPLAN` | A commercial tier (STD/PRO/ENT-style), optionally scoped to a partner product. |
| **Subscription** | `MENTITLEMENTSUBSCRIPTION` | One row per client — the tenant's commercial state: plan, status, trial flag, validity dates. |
| **Entitlement** | `MENTITLEMENT` | The resolved per-subscription grant of a single feature, tagged with its source (Plan/Addon/Promotion/AdminOverride). |
| **License / Assignment** | `MENTITLEMENTLICENSE` / `...ASSIGNMENT` | A seat pool per delivery channel (Web/Mobile/Portal/API); assignments record who holds a seat. |
| **FeatureFlag / Kill-switch** | `MENTITLEMENTFEATUREFLAG` | Per-feature release control: Off/On/KillSwitch status + rollout strategy. Kill-switch is absolute and un-bypassable. |
| **BetaClient / Beta Program** | `MENTITLEMENTBETACLIENT` | A client's enrollment in a named beta programme for a validity window. |
| **Bundle** | `MENTITLEMENTBUNDLE` | An ECDSA-signed, verifiable JSON snapshot of a client's fully-resolved entitlements — what an offline client trusts. |
| **ChangeRequest** | `MENTITLEMENTCHANGEREQUEST` | A client-submitted proposed change (plan change / renewal / beta enrol / beta opt-out) that mutates nothing until a staff member approves it. |
| **Client User + Capability** | `MENTITLEMENTCLIENTUSER` / `...CLIENTUSERROLE` | External portal login (CLIENT_ADMIN / CLIENT_USER), optionally layered with capability grants (TECH_ADMIN / COMMERCIAL_ADMIN). |
| **MenuSetMap** | `MENTITLEMENTFEATUREMENUSETMAP` | Maps a feature to a menu-set, gating which application menus appear when a feature is entitled. |
| **Provisioning Job** | `MPROVISIONINGJOB` | Tracks the async creation of a new client (QuickStart vs Managed), with idempotency via engagement reference. |

---

## 3. Database schema / ER diagram

All tables are SQL Server + PostgreSQL (identical logical schema, written from day one for both). `M`-tables use app-generated `INT` PKs via `MAUTONUMBER` (no `IDENTITY`); the two `L`-tables (`LENTITLEMENTAUDIT`, `LFEATUREFLAGAUDIT`) use `BIGINT IDENTITY(1,1)` since they're pure append-only logs.

### 3.1 Core commercial model

```mermaid
erDiagram
    MCLIENT ||--o| MENTITLEMENTSUBSCRIPTION : "has one"
    TPARTNERPRODUCT |o--o{ MENTITLEMENTPLAN : "scopes (nullable)"
    MENTITLEMENTPLAN ||--o{ MENTITLEMENTSUBSCRIPTION : "chosen by"
    MENTITLEMENTFEATUREGROUP ||--o{ MENTITLEMENTFEATURE : groups
    MENTITLEMENTPLAN ||--o{ MENTITLEMENTPLANFEATURE : grants
    MENTITLEMENTFEATURE ||--o{ MENTITLEMENTPLANFEATURE : "granted via"
    MENTITLEMENTSUBSCRIPTION ||--o{ MENTITLEMENT : resolves
    MENTITLEMENTFEATURE ||--o{ MENTITLEMENT : "resolved for"
    MENTITLEMENTSUBSCRIPTION ||--o{ MENTITLEMENTLICENSE : "seat pools"
    MENTITLEMENTLICENSE ||--o{ MENTITLEMENTLICENSEASSIGNMENT : assigns
    MUSER ||--o{ MENTITLEMENTLICENSEASSIGNMENT : holds
    MCLIENT ||--o{ MENTITLEMENTBUNDLE : "signed snapshot"
    MENTITLEMENTFEATURE ||--o{ MENTITLEMENTFEATUREMENUSETMAP : gates
    MCLIENT ||--o{ LENTITLEMENTAUDIT : "audit trail"

    MCLIENT {
        int ClientId PK
        byte DeploymentType "0=OnPrem 1=Cloud 2=Hybrid"
        byte SubscriptionStatus "denormalized copy"
        byte TrialMode
    }
    MENTITLEMENTPLAN {
        int PlanId PK
        string PlanCode
        string PlanName
        int PartnerProductId FK "nullable"
        string PieConfigProfileCode
    }
    MENTITLEMENTFEATUREGROUP {
        int FeatureGroupId PK
        string FeatureGroupCode UK
        string FeatureGroupName
    }
    MENTITLEMENTFEATURE {
        int FeatureId PK
        string FeatureCode UK
        string FeatureName
        int FeatureGroupId FK
        byte FeatureType "0=Bool 1=Quota 2=Config"
        bool IsExposedToPie
    }
    MENTITLEMENTPLANFEATURE {
        int PlanFeatureId PK
        int PlanId FK
        int FeatureId FK
        bool IsEnabled
        string FeatureValue "JSON, e.g. quota"
    }
    MENTITLEMENTSUBSCRIPTION {
        int SubscriptionId PK
        int ClientId FK UK
        int PlanId FK
        byte SubscriptionStatus "0=Pending 1=Active 2=Expired 3=Suspended"
        bool TrialFlag
        datetime LicenseValidTill
        int GracePeriodDays
    }
    MENTITLEMENT {
        int EntitlementId PK
        int SubscriptionId FK
        int FeatureId FK
        bool IsEnabled
        byte EntitlementSource "0=Plan 1=Addon 2=Promotion 3=AdminOverride"
        datetime ValidUntil
    }
    MENTITLEMENTLICENSE {
        int LicenseId PK
        int SubscriptionId FK
        byte LicenseType "0=Web 1=Mobile 2=Portal 3=API"
        int Seats
    }
    MENTITLEMENTLICENSEASSIGNMENT {
        int LicenseAssignmentId PK
        int LicenseId FK
        int UserId FK
        datetime RevokedOn "NULL = active seat"
    }
    MENTITLEMENTBUNDLE {
        int BundleId PK
        int ClientId FK
        string BundleJson
        string Signature "ECDSA P-256"
        bool IsCurrent
    }
    MENTITLEMENTFEATUREMENUSETMAP {
        int FeatureMenuSetMapId PK
        int FeatureId FK
        int MenuSetId "no enforced FK"
        bool IsDefault
    }
    LENTITLEMENTAUDIT {
        bigint LEntitlementAuditId PK
        int ClientId FK
        string Action
    }
```

### 3.2 Feature flags / rollout control

```mermaid
erDiagram
    MENTITLEMENTFEATURE ||--o| MENTITLEMENTFEATUREFLAG : "one flag per feature"
    MENTITLEMENTFEATUREFLAG ||--o{ MENTITLEMENTFEATUREFLAGTARGET : overrides
    MENTITLEMENTFEATUREFLAG ||--o{ LFEATUREFLAGAUDIT : "audit trail"
    MCLIENT ||--o{ MENTITLEMENTBETACLIENT : enrolls

    MENTITLEMENTFEATUREFLAG {
        int FeatureFlagId PK
        int FeatureId FK UK
        string FlagName
        byte FlagStatus "0=Off 1=On 2=KillSwitch"
        byte RolloutType "0=Global 1=BetaClients 2=SelectedClients 3=SelectedUsers 4=Percentage"
        int RolloutPercent "0-100"
        string KillSwitchReason
    }
    MENTITLEMENTFEATUREFLAGTARGET {
        int FlagTargetId PK
        int FeatureFlagId FK
        byte TargetType "0=Client 1=User"
        int TargetClientId
        int TargetUserId
        bool IsEnabled
    }
    MENTITLEMENTBETACLIENT {
        int BetaClientId PK
        int ClientId FK
        string BetaProgramName
        datetime ValidUntil
    }
    LFEATUREFLAGAUDIT {
        bigint LFeatureFlagAuditId PK
        int FeatureFlagId FK
        string Action
    }
```

### 3.3 Client portal auth + change requests

```mermaid
erDiagram
    MCLIENT ||--o{ MENTITLEMENTCLIENTUSER : "external logins"
    MENTITLEMENTCLIENTUSER ||--o{ MENTITLEMENTCLIENTREFRESHTOKEN : "refresh chain"
    MENTITLEMENTCLIENTUSER ||--o{ MENTITLEMENTCLIENTUSERROLE : "capability grants"
    MCLIENT ||--o{ MENTITLEMENTCHANGEREQUEST : submits
    MENTITLEMENTSUBSCRIPTION |o--o{ MENTITLEMENTCHANGEREQUEST : "target (nullable)"
    MENTITLEMENTCLIENTUSER ||--o{ MENTITLEMENTCHANGEREQUEST : "requested by"
    MCLIENT ||--o{ MPROVISIONINGJOB : provisions
    MENTITLEMENTPLAN ||--o{ MPROVISIONINGJOB : "initial plan"

    MENTITLEMENTCLIENTUSER {
        int ClientUserId PK
        int ClientId FK
        string Email "unique per client, not globally"
        string PasswordHash "PBKDF2-SHA256"
        byte Role "0=CLIENT_ADMIN 1=CLIENT_USER"
        byte Status "1=Active 2=Locked 3=Deleted"
    }
    MENTITLEMENTCLIENTREFRESHTOKEN {
        int ClientRefreshTokenId PK
        int ClientUserId FK
        string TokenHash "SHA-256, raw token never stored"
        int ReplacedByTokenId FK "self-ref rotation chain"
    }
    MENTITLEMENTCLIENTUSERROLE {
        int ClientUserRoleId PK
        int ClientUserId FK
        string RoleCode "TECH_ADMIN | COMMERCIAL_ADMIN, free-text"
        byte Status "1=Active 2=Revoked"
    }
    MENTITLEMENTCHANGEREQUEST {
        int ChangeRequestId PK
        int ClientId FK
        byte RequestType "0=ChangePlan 1=Renew 2=BetaEnrol 3=BetaOptOut"
        int SubscriptionId FK "nullable"
        int ProposedPlanId "ChangePlan only"
        byte RequestStatus "0=Pending 1=Approved 2=Rejected"
        int RequestedByClientUserId FK
        int ReviewedByUserId "nullable, internal MUSER"
    }
    MPROVISIONINGJOB {
        int ProvisioningJobId PK
        int ClientId FK UK
        int PlanId FK
        byte ProvisioningMode "0=QuickStart 1=Managed"
        byte JobStatus "0=Queued 1=Running 2=Completed 3=Failed 4=PartialFail"
    }
```

---

## 4. Feature-resolution algorithm — the 9 layers

`EntitlementBLL/Implementations/EntitlementService.cs` implements this as the single, hard-coded resolution path — no caller, including GOODBOOKS_ADMIN, can skip an earlier layer's deny. Called by `Entitlement.svc/IsFeatureEnabled` (public) and internally for bundle issuance.

| # | Layer | Rule |
|---|---|---|
| 1 | **Kill switch (absolute)** | If the feature's flag has `FlagStatus = KillSwitch`, deny. Cannot be bypassed by any role. |
| 2 | **Not licensed** | If a grant exists with `IsEnabled = false`, deny. (If no flag row exists at all, resolution short-circuits here to a simple licensed/not-licensed + validity-window check.) |
| 3 | **Globally off** | If `FlagStatus = Off`, deny. |
| 4 | **Per-user override** | A `MENTITLEMENTFEATUREFLAGTARGET` row for this specific user wins immediately (allow or deny). |
| 5 | **Per-client override → beta membership** | A per-client target row wins; otherwise, if rollout is `BetaClients`, membership in the beta programme named the same as the flag grants access. |
| 6 | **Percentage rollout** | Deterministic bucket = `SHA256(userId + featureCode)` mod 100, compared against `RolloutPercent`. |
| 7 | **Global-on validity window** | Only when flag is `On` + rollout is `Global`: deny if the grant is outside its `ValidFrom`/`ValidUntil` window. |
| 8 | **Quota check** | For `Quota`-type features, deny if active seat assignments ≥ the Web license pool's seat count. |
| 9 | **Default allow** | Otherwise, allow. |

**Known limitation** (flagged, not a defect to "fix" without redesign): beta-programme correlation happens by matching `FlagName` to `BetaProgramName` as strings — not a dedicated FK — a documented design choice, worth knowing before renaming a flag. Layer 8's quota check also has no live usage counter in Phase 1; it reads the configured quota value but computes usage from the Web seat-assignment count as a proxy, not a true per-quota-type usage ledger.

A parallel, coarser classifier (`ResolveBundleFlagStateAsync`) maps the same flag state into a client-facing `BundleFlagStateEnum` (Normal / Hidden / Maintenance / BetaPreview) for the signed bundle — it mirrors layers 1/2/3/5 but omits the per-user layers 4 and 6, since a bundle is issued per-client with no specific user in scope (those stay live-checked per request). Kill-switch is surfaced to clients as **"Maintenance"**, never literally "Kill Switch."

### Bundle signing

`EntitlementBundleService.IssueSignedBundleAsync`: canonicalizes the resolved payload (sorted keys, no whitespace) → SHA-256 → ECDSA P-256 sign (DER) → base64 signature. Issuing a new bundle expires the prior "current" row for that client in the same transaction (only one `IsCurrent = true` row per client at any time). If no real private key is configured, a process-lifetime ephemeral dev key is used with a loud warning — never silent in a real environment.

---

## 5. Change-request / approval workflow

Deliberately **not** routed through GB5's shared `WorkFlowEngine` — confirmed no module in the repo has ever seeded live workflow config rows, and the engine has no native "route to whoever holds a named capability" assignee strategy. Entitlement owns a small, single-review-step table instead (`MENTITLEMENTCHANGEREQUEST`); revisit shared-engine adoption once it has a first proven consumer elsewhere.

**State machine** — `RequestStatus`: `Pending → Approved` or `Pending → Rejected`, one-shot, no re-open, no escalation/delegation/SLA.

```mermaid
stateDiagram-v2
    [*] --> Pending: Client submits request
    Pending --> Approved: GOODBOOKS_ADMIN approves\n(real mutation runs FIRST)
    Pending --> Rejected: GOODBOOKS_ADMIN rejects\n(no mutation)
    Approved --> [*]
    Rejected --> [*]
```

**Who can submit what:**

| Request type | Who can submit | Extra gate |
|---|---|---|
| Change Plan | CLIENT_ADMIN | + `COMMERCIAL_ADMIN` capability |
| Renew | CLIENT_ADMIN | + `COMMERCIAL_ADMIN` capability |
| Beta Enrol | CLIENT_ADMIN | none |
| Beta Opt-Out | CLIENT_ADMIN | none |

The `COMMERCIAL_ADMIN`/`TECH_ADMIN` distinction (`MENTITLEMENTCLIENTUSERROLE`) is a capability layered *on top of* the base `CLIENT_ADMIN`/`CLIENT_USER` role — resolved once at login/refresh and carried on the JWT's `Capabilities` claim, so no DB round-trip is needed per request to check it.

**Approval mechanics** (important for reliability): on approve, the real underlying mutation (`SubscriptionService.ChangePlanAsync`/`RenewAsync`, or `BetaClientBLL.EnrolAsync`/`OptOutAsync`) is dispatched **first**, inside the same transaction as marking the request Approved. If the mutation throws, the request stays `Pending` for retry rather than being incorrectly marked Approved with nothing having actually happened. Reject never touches the underlying subscription/beta state at all.

Every submit/approve/reject publishes a real event (`ENTITLEMENTCHANGEREQUESTSUBMITTED`/`APPROVED`/`REJECTEDEVENTTYPEID`) for the audit trail.

---

## 6. Backend endpoints reference

All routes prefixed `/lic/`. Two coexisting auth models:
- **Internal staff**: `[MenuRights("<menucode>", RightOperation.X)]` + `AllowAnonymous()` on the endpoint (the `[MenuRights]` attribute is the real gate — it resolves against the caller's own tenant `MROLEVSMENU`, the same mechanism every other GB5 admin screen uses).
- **External client portal**: `AuthSchemes("ClientJwtBearer")` + `Roles(ClientAdmin, ClientUser)`, sometimes with an additional in-body `ClientCallerContext` ownership/capability check.

| Area | Endpoint | Method | Auth |
|---|---|---|---|
| **Audit** | `Audit.svc/GetEntitlementLog` | GET | MenuRights `entaudit` |
| | `Audit.svc/GetFlagLog` | GET | MenuRights `entaudit` |
| **BetaClient** | `BetaClient.svc/GetList` / `Save` / `Delete` | GET/POST/DELETE | MenuRights `entbetaclients` |
| | `BetaClient.svc/GetOpenPrograms` | GET | AllowAnonymous (public reference data) |
| | `BetaClient.svc/GetMyEnrollments` | GET | ClientJWT (own client) |
| **ChangeRequest** | `ChangeRequest.svc/GetList` / `Approve` / `Reject` | GET/POST | MenuRights `entchangerequests` |
| | `ChangeRequest.svc/RequestChangePlan` / `RequestRenewal` | POST | ClientJWT + `COMMERCIAL_ADMIN` |
| | `ChangeRequest.svc/RequestBetaEnrol` / `RequestBetaOptOut` | POST | ClientJWT (ClientAdmin) |
| | `ChangeRequest.svc/GetMyRequests` | GET | ClientJWT (own client) |
| **ClientAuth** | `ClientAuth.svc/Login` / `RefreshToken` / `Logout` / `ForgotPassword` / `ResetPassword` | POST | AllowAnonymous (credential is the gate) |
| | `ClientAuth.svc/ChangePassword` | POST | ClientJWT |
| | `ClientAuth.svc/CreateClientUser` | POST | ClientJWT (ClientAdmin, own client) |
| | `ClientAuth.svc/CreateClientAdmin` / `SetClientUserStatus` | POST | MenuRights `entdashboard` |
| **ClientProvisioning** | `ClientProvisioning.svc/CreateClient` | POST | MenuRights `entclientprovisioning` |
| **Provisioning** | `Provisioning.svc/Provision` / `Status` | POST/GET | AllowAnonymous (prospect self-service) |
| **Entitlement** | `Entitlement.svc/GetBundle` | GET | MenuRights `ententitlements` |
| | `Entitlement.svc/GetMyBundle` | GET | ClientJWT (own client) |
| | `Entitlement.svc/GetList` / `GetFeatureList` / `Save` / `Delete` | — | MenuRights `ententitlements` |
| | `Entitlement.svc/IsFeatureEnabled` | GET | AllowAnonymous (the resolution engine itself) |
| **Feature** | `Feature.svc/Get` / `Save` / `Delete` | — | MenuRights `entfeatures` |
| | `Feature.svc/GetList` | GET | AllowAnonymous — only `IsExposedToPie=1` features to anonymous callers |
| **FeatureFlag** | `FeatureFlag.svc/Get` / `GetList` / `Save` / `GetAuditLog` | — | MenuRights `entfeatureflags` |
| | `FeatureFlag.svc/ActivateKillSwitch` / `LiftKillSwitch` | POST | MenuRights `entfeatureflags` |
| | `FeatureFlag.svc/AddTarget` / `RemoveTarget` / `GetTargetList` | — | MenuRights `entflagtargets` |
| **FeatureGroup** | `FeatureGroup.svc/*` (CRUD) | — | MenuRights `entfeaturegroups` |
| **License** | `License.svc/GetSeats` / `GetUtilisation` / `UpdateSeats` | — | MenuRights `entlicenses` |
| | `License.svc/GetMySeats` / `GetMyUtilisation` | GET | ClientJWT (own client) |
| **MenuSetMap** | `MenuSetMap.svc/*` (CRUD) | — | MenuRights `entmenusetmap` |
| **Plan** | `Plan.svc/Get` / `GetList` / `GetPlanFeatures` | GET | AllowAnonymous (public pricing catalogue) |
| | `Plan.svc/Save` / `Delete` | POST/DELETE | MenuRights `entplans` |
| **Subscription** | `Subscription.svc/Get` / `GetList` / `Save` / `ChangePlan` / `Renew` / `SetStatus` | — | MenuRights `entsubscriptions` |
| | `Subscription.svc/GetMySubscription` | GET | ClientJWT (own client) |

There is also a Dapr subscriber, **not** under `Endpoints/`: `EntitlementSL/Subscriptions/CapabilityDisabledSubscriber.cs` — consumes an EAP `CapabilityDisabledEvent` and activates the matching kill-switch, mapping capability name → feature code via a hardcoded `"EAP." + CapabilityName` convention (flagged as a TODO in source, not schema-backed).

---

## 7. Frontend screens

### `gb-ent-admin` — internal staff console (15 screens, Native-Federation remote inside the `admin` project)

Dashboard · Feature Groups · Features · Plans · Plan-Feature Matrix · Subscriptions · Entitlements · Licenses · Feature Flags (most complex screen: status, rollout, kill-switch) · Flag Targets · Beta Clients · Bundles (signed-bundle viewer) · Menu Set Map · Audit (two tabs: Entitlement / Flag) · **Approval Inbox** (Change Requests — the staff-side counterpart to the client's "My Requests").

### `gb-ent-client` — public + authenticated portal (standalone Angular app, real Router routes)

**Public/prospect surface** (`/public/*`): Home · Pricing (3-tier comparison grouped by feature group) · Contact Sales · Demo (read-only sandbox) · Trial Signup · Provisioning Status (polls job progress) · QuickStart wizard (Details → Configure → Choose Plan → Create Account) · Login/Forgot/Reset Password.

**Authenticated self-service surface** (`/client/*`, route-guarded): Dashboard · Subscription (full detail, entitlements grouped) · Features (read-only) · Billing (CLIENT_ADMIN only — subscription summary + Renew) · Beta (CLIENT_ADMIN only — enrol/opt-out) · **My Requests** (change-request history) · Users (CLIENT_ADMIN only) · Profile (company info + change password).

---

## 8. Known gaps / where this stops today

For the full, continuously-updated status and priority list, see the master tracker. The short version, as of this writing:

- **DB provisioning is not automated** — client identity (`MCLIENT`/`MUSER`) creation, per-tenant DB creation, migration running, and reference-data seeding are all still manual/design-stage. `MPROVISIONINGJOB` tracks the *idea* of a job; the automation behind most of its steps doesn't exist yet.
- **`GOODBOOKS_ADMIN` has no backing auth scheme in a standalone deployment** — the `[MenuRights]` mechanism itself is real and correctly wired; what's missing is a real tenant's seeded `MUSER`/`MROLE`/`MROLEVSMENU` rows for whoever operates a self-hosted instance.
- **Entitlement ↔ Payment billing boundary is undecided** — which system is the source of truth for "is this subscription actually paid" was never settled.
- **PIE `profile_code` mapping** is an open cross-team item, not yet resolved.
- Everything above is tracked with priority/criticality/readiness in the master tracker — this doc won't be kept in lockstep with day-to-day status changes there.
