Skip to content

Middleware Composition: Permission, TierEntitlement, OrgEntitlement, Limit

How the four authorization-adjacent gates compose at the request boundary. Companion to rbac-permissions.md (the permission model), tiers-and-subscriptions.md (where tier entitlements / limits / org entitlements come from), and org-settings.md (where org entitlements live).

Why this exists. It is tempting to collapse "the user is allowed", "the SKU includes this", "the regulated boundary is open", and "the meter is under cap" into a single check. That is wrong: the four answers come from four different sources, are owned by four different teams (RBAC / billing / regulatory / capacity), have four different error codes, and have four different remediation paths. Conflating them obscures audits and makes one wrong gate hide another. This doc fixes the model.


The four concepts

Mental shorthand:

ConceptQuestionSourced fromFailure codeRemediation surface
PermissionIs the verb allowed for this caller?Caller's per-org role + permissions403 permission_deniedAsk an admin to grant the role / change tenant
TierEntitlementIs this entitlement included in the org's SKU?Active subscriptions' organization_subscription_entitlements402 tier_entitlement_unavailableUpgrade tier / contact sales
OrgEntitlementIs this regulated boundary open for this org?organization_entitlements403 org_entitlement_disabledContact platform support (regulatory state)
LimitIs the meter under cap for this resource?organization_subscription_limits + usage_counters402 limit_exceededUpgrade / buy a usage pack / wait for period reset

Why permission and org-entitlement are both 403 but tier-entitlement and limit are both 402:

  • 402 = "Payment Required" — a billing surface fix. Both missing tier entitlement and exceeded cap are resolved by buying something. The error body always carries an upgrade path.
  • 403 = "Forbidden" — not a billing surface. Either the caller lacks a role grant (permission), or the platform itself has the regulated boundary closed for this org (org entitlement). No amount of paying changes either; the fix is administrative.

The two 403s and the two 402s are distinguishable by an error discriminator in the body so the client can show the right UX.

Sitting alongside the four-gate model — but expressed as a precondition rather than an authorization check — are two consent middlewares:

ConceptQuestionSourced fromFailure codeRemediation surface
Re-consent (version drift)Has this principal accepted the current versions of every required (legal_basis != 'consent') consent purpose at the resolved scope?consent_purpose_versions × consents ledger (P17)412 consent_requiredPortal renders a blocking re-consent modal driven by /v1/me/required-consents, posts grants to /v1/me/consents, retries the original request
Per-purpose opt-inHas this principal granted the specific purpose this route requires (e.g. telerehab, biometric capture, video recording)?consents ledger (P17), single purpose code resolved at gate-construction time403 consent_requiredPortal prompts the patient to grant the named purpose (details.missing_purpose); on success, retry the original request

The first (RequireCurrentConsents) covers version drift on previously accepted required purposes. The second (RequireConsent(code)) is a single-purpose opt-in gate parameterised at route-construction time — it is a stub today (no production route consumes it) and lights up when the first per-purpose-gated feature ships (F3 Tier-B medical consents, F9 telerehab, biometric / video — Layer 7+).

Neither is part of the four-gate model and they use different status codes on purpose. A 403 means "you may not do this" (the caller is wrong, or never opted in). A 412 means "the precondition for this request is not met" (the request shape is fine, but the world has moved — a clinic published a new privacy notice, a platform term bumped). Conflating with 403 in the version-drift case would route the user through "ask an admin to grant the role" UX, which is unrelated; conflating with 412 in the opt-in case would imply the patient had a prior grant that expired, which is wrong. Full implementation rationale under Principal-portable branch and re-consent gating; placement in the middleware chain is described in Order of evaluation at step [2.7].


Order of evaluation

Strictly in this order. Each gate fail short-circuits the rest. The diagram below shows every wired middleware for /v1/* requests; the route tree branches at step [2] into the org-scoped path (RequireOrganizationScope, where the four-gate Permission / TierEntitlement / OrgEntitlement / Limit model lives) and the principal-portable /me/* path (RequirePrincipalRLS — see Principal-portable branch and re-consent gating). ActivityTracker and RequireCurrentConsents are cross-cutting — they apply on either branch wherever the prerequisites are met.

Request


[0]   RateLimit (per-IP)             → 429 rate_limited
                                       (runs BEFORE Authenticate so a
                                        client cannot burn JWT
                                        verification with bursts)


[1]   Authenticate                      → 401 if invalid token


[2]   Scope resolution               → branch:
                                         org-scoped:
                                           ResolveOrganizationContext
                                             (parser; no gate)
                                           +
                                           RequireOrganizationScope
                                             (403 if caller has no
                                              membership; acquires
                                              AppPool tx + full org-scope
                                              RLS session vars)
                                         /me/* portable:
                                           ResolveOrganizationContext
                                           +
                                           RequirePrincipalRLS
                                             (acquires AppPool tx + sets
                                              `app.current_principal_id`
                                              WITHOUT the org-scope 403)


[2.5] ActivityTracker                → cross-cutting; runs on both
                                       branches after scope is set.
                                       Bumps humans.last_activity +
                                       organization_memberships.last_used_at
                                       (or patients.last_used_at on a
                                       patient session). Throttled to
                                       ~once/min, async, never blocks.


[2.7] RequireCurrentConsents         → 412 consent_required
                                       (mounted today only on the gated
                                        sub-group of /me/*; the
                                        consent-acceptance and step-1
                                        onboarding endpoints sit in a
                                        sibling group that does NOT
                                        compose it, so a 412'd patient
                                        can unblock themselves. Body
                                        carries the missing
                                        `[{purpose_code, version}]` list.)

      Sibling: RequireConsent(code)  → 403 consent_required
                                       (single-purpose opt-in gate;
                                        purpose code bound at route
                                        construction. Stub today; lights
                                        up when the first per-purpose
                                        feature ships — F3 Tier-B
                                        medical consents, F9 telerehab,
                                        biometric / video — Layer 7+.
                                        Body carries
                                        `details.missing_purpose`.)


[2.9] RequireURLOrgMatchesScope("id")→ 403 if URL `{id}` ≠ header-resolved
                                       scope (mounted on every
                                       /organizations/{id}/* route group;
                                       cache-leak guard per the URL ≡
                                       scope rule; superadmin bypasses).


[3]   RequirePermission(code)        → 403 permission_denied


[4]   RequireTierEntitlement(code)   → 402 tier_entitlement_unavailable   (aspirational)


[5]   RequireOrgEntitlement(code)    → 403 org_entitlement_disabled       (aspirational)


[6]   EnforceLimit(code, delta)      → 402 limit_exceeded                 (aspirational)
                                       (if hard_block; otherwise
                                        pass + emit usage_event)


Handler


RLS at the database (defense in depth)

Aspirational gates ([4]–[6]). RequireTierEntitlement / RequireOrgEntitlement / EnforceLimit are documented here as the canonical four-gate composition but no production route wires them today — see Layer 1 reservation for what ships now vs deferred. Subject.HasTierEntitlement returns true and Limit returns "unlimited" until the resolvers exist.

Superadmin sibling at [3]. When a route is superadmin-only (cross-tenant operations: platform-wide listings, org-entitlement writes, sales overrides, platform consent-version publishes, console template management) it composes RequireSuperadmin() instead of (not in addition to) RequirePermission. The two are mutually exclusive at the route level — RequireSuperadmin is bypass-the-tenant-checks; RequirePermission is gated-by-permission. Wired inline today on the platform-wide /v1/users, /v1/audit-logs, the /v1/admin/legal-document-templates and /v1/admin/platform-consent-purpose-versions console surfaces, organization create/list, the regulated /v1/organizations/{id}/entitlements PATCH, and subscription-override grant/revoke — appears at 9 sites in routes.go (grep -n RequireSuperadmin services/api/internal/core/server/routes.go).

Reasoning for the order:

  1. Rate-limit before auth (0). Token verification is the first expensive operation; capping requests per IP before Authenticate means a client cannot DoS the auth path with bursts of bearer tokens. Per-tenant per-endpoint limiting on authenticated routes is a Layer 12 follow-up.
  2. Authentication second (1). Nothing else is meaningful without an authenticated principal.
  3. Scope resolution third (2). ResolveOrganizationContext is purely a parser — it places CurrentOrganizationID on Subject without gating. Branching the route tree, not this middleware, decides whether scope is required: org-scoped routes compose RequireOrganizationScope (which 403s on missing membership and acquires the AppPool tx with full org-scope RLS session vars — set_config(..., true), transaction-scoped, see feedback_rls_session_vars_via_ctx_conn); /me/* routes compose RequirePrincipalRLS instead, which acquires the same AppPool tx but only sets app.current_principal_id (the patient_profiles RLS policy keys on current_human_patient_profile_ids(), not on current_app_org_id(), so a clinic-A patient visiting clinic B's portal can still read their own profile).
  4. Activity tracker (2.5). Pure observability — never blocks, never short-circuits. Cross-cutting: composes on both branches (/me/* and org-scoped) immediately after scope resolution so the bump is keyed on the right (principal, org) or (principal,) pair. Operational-metadata-bump exemption from audit applies (see CLAUDE.md → Audit Logging).
  5. Re-consent gate (2.7). Returns 412 Precondition Failed (NOT 403) — the precondition for the request is a current grant on every legal_basis != 'consent' purpose at the resolved scope. Order-critical: composes AFTER RequirePrincipalRLS so the helper's read against consents lands on the request's RLS-scoped tx. Today wired only on the gated sub-group of /me/*; future staff Terms-of-Service version-drift gates land here without changing the middleware. Full rationale under Principal-portable branch and re-consent gating.
  6. URL ≡ scope guard (2.9). Mounted on /organizations/{id}/* only; rejects requests where the URL {id} disagrees with the header-resolved scope. Without it, RLS hides one mismatched response and the cache layer (P42/P45) can propagate that response to every future caller — see decisions.md → Why URL ≡ scope guard. Composition placement, not a top-level gate: RequireURLOrgMatchesScope("id") is mounted inside the RequireOrganizationScope group on the per-resource route group r.Route("/{id}", ...) — not on the parent /v1/organizations group, which has to remain reachable for the superadmin-only list/create endpoints (no {id} URL parameter exists there to compare against). Today there is exactly one mounting point in services/api/internal/core/server/routes.go: the r.Use(middleware.RequireURLOrgMatchesScope("id")) call inside the per-resource r.Route("/{id}", ...) group, nested inside the r.Route("/organizations", ...) group, nested inside the org-scoped group that composes RequireOrganizationScope (grep -n RequireURLOrgMatchesScope services/api/internal/core/server/routes.go to locate). Every per-org resource (members, domains, settings, billing, entitlements, legal-documents, patient-tiers, patients, patient-subscriptions, subscriptions, and their nested sub-paths) is reached through that /{id} group and inherits the guard. Cache safety depends on the nesting: without P47, an A-member request shaped as GET /v1/organizations/B/... would pass RequireOrganizationScope (the principal IS a member of A) and reach the handler with the URL pointing at B — RLS would hide the row, but the cache key under P42/P45 is the URL, so the response gets stored and served to every subsequent caller asking for B. P47 closes the gap at the route layer before the cache is consulted.
  7. Permission third-from-last (3). Cheapest of the four-gate model (in-memory from Subject) and the most fundamental — a caller without the verb shouldn't even disclose the SKU / org-entitlement / limit state of the org.
  8. TierEntitlement (4). Discloses commercial state ("this is a Pro entitlement") only after we know the caller had the right to do it. Avoids leaking SKU information to unauthorized callers.
  9. OrgEntitlement (5). Discloses regulatory state ("we don't enable telerehab for your jurisdiction") even later. This is operationally sensitive; only callers who would otherwise succeed should learn about it.
  10. Limit last (6). Most expensive — usually a counter read, possibly a SQL query. Run it last so we don't pay the cost on requests that fail earlier.

The order also produces clean error precedence: a Free-tier caller without automations.manage permission gets 403 permission_denied (not 402 tier_entitlement_unavailable) — accurate to their actual problem.


A subset of authenticated routes — everything mounted under /v1/me/* — does not require an active org membership. A clinic-A patient visiting clinic B's portal still needs to read their own patient_profiles row to confirm before joining clinic B; staff principals authenticated against the platform still need to call /v1/me for the post-sign-up redirect decision. The org-scope 403 from RequireOrganizationScope is wrong for these.

After step [2] (ResolveOrganizationContext), the route table branches into a sibling group composed as:

RequirePrincipalRLS                   → acquires AppPool tx + sets
                                        `app.current_principal_id`
                                        WITHOUT the org-scope 403.
                                        The patient_profiles RLS policy
                                        keys on
                                        current_human_patient_profile_ids(),
                                        not on current_app_org_id().


ActivityTracker                       → same shared middleware as [2.5].


   ├── consent endpoints + step-1 onboarding
   │   (/me/consents{,/{id}/withdraw},
   │    /me/required-consents,
   │    POST /me/patient-profile)
   │   No further gate — these are the
   │   endpoints that LET an un-consented
   │   patient unblock themselves.

   └── everything else under /me/*


       RequireCurrentConsents         → 412 consent_required
                                        with body
                                        { error: { code: "consent_required",
                                                   message: "...",
                                                   missing: [{purpose_code,
                                                              version}, ...] } }


       Handler

Why RequireCurrentConsents is order-critical and 412 (not 403)

Reference: services/api/internal/core/middleware/consents.goRequireCurrentConsents for the version-drift gate, RequireConsent for the per-purpose feature opt-in stub. The 412 path calls consents.Repository.ListRequiredVersions (services/api/internal/core/domain/consents/repository.go), which is a thin wrapper over the SQL helper current_required_consent_versions(principal_id UUID, organization_id UUID) defined in services/api/migrations/core/000008_consents.up.sql. The helper returns the (purpose_code, version) pairs the principal still owes at the resolved scope (platform purposes always; the current org's purposes when an org is in scope), and prefers per-org overrides over platform defaults — the same shape the response body's missing array carries. Backed by the consents ledger (P17 — Foundation 1B.9 shipped).

  • Composes after RequirePrincipalRLS, never before. The middleware reads consent_purpose_versions against the caller's RLS-scoped tx (see the package doc on RequireCurrentConsents in services/api/internal/core/middleware/consents.go). Without the principal-RLS plumbing already on the request context, the helper falls through to an app-pool SELECT against catalog-public state — safe but outside the request's transaction boundary, which breaks the "audit rows ≡ real DB mutations" invariant for the rest of the chain.
  • Sibling-group placement, not URL allow-list. The endpoints that let a 412'd patient unblock themselves (/me/consents, /me/required-consents, POST /me/patient-profile) are mounted in a sibling group that does not compose RequireCurrentConsents. A URL allow-list inside the middleware would silently drift as routes move; sibling groups make the boundary visible at the route table.
  • 412, not 403. A 403 means "you may not do this" (permission, org-entitlement, scope). A 412 means "the precondition for this request is not met" — version drift on a previously accepted consent. Different remediation: the portal renders a blocking re-consent modal that drives /me/consents POSTs, then retries the original request. Conflating with 403 would route through the "ask an admin to grant the role" UX, which has nothing to do with consent versioning.
  • Required vs withdrawable. The middleware fires only on consents whose legal_basis != 'consent' (contract, legitimate_interest, legal_obligation, vital_interest). Withdrawable consent-basis toggles never block a request even when un-accepted; the patient is free to decline marketing, AI processing, etc.
  • RequireConsent(code) is a sibling, not a replacement. The single-purpose gate RequireConsent(code) (services/api/internal/core/middleware/consents.go) is for feature-tier opt-ins (telerehab, AI inference, biometric capture) where the patient never opted into a specific feature in the first place. It returns 403 consent_required to distinguish from the 412 version-drift case. F3 (forms / Tier-B medical consents) and F9 (telerehab) are the future consumers; no production route wires it today.

The four-gate model in this doc (Permission / TierEntitlement / OrgEntitlement / Limit) does not apply on the /me/* branch — those gates are tenant-scoped, and /me/* is principal-scoped by construction. RequireCurrentConsents is the only authorization-adjacent gate that runs there.


Error response shape

All four gates return JSON bodies with a discriminating error field plus context. Examples:

jsonc
// 403 from RequirePermission
{
  "error": "permission_denied",
  "missing_permission": "patients.update_org",
  "request_id": "..."
}

// 402 from RequireTierEntitlement
{
  "error": "tier_entitlement_unavailable",
  "missing_entitlement": "automations",
  "current_tier": "free",
  "upgrade_url": "https://app.restartix.pro/billing/upgrade?entitlement=automations",
  "request_id": "..."
}

// 403 from RequireOrgEntitlement
{
  "error": "org_entitlement_disabled",
  "missing_entitlement": "telerehab",
  "request_id": "..."
}

// 402 from EnforceLimit (hard_block)
{
  "error": "limit_exceeded",
  "limit": "max_patients",
  "current": 1000,
  "cap": 1000,
  "upgrade_url": "https://app.restartix.pro/billing/upgrade?limit=max_patients",
  "request_id": "..."
}

// 200 with metering header (soft_meter — request succeeds)
HTTP/1.1 200 OK
X-RateLimit-Resource: video_minutes_per_month
X-RateLimit-Used: 8523
X-RateLimit-Cap: 10000

Both 402 responses carry an upgrade_url. Org-entitlement 403 does not — there is no buy-button for "we don't have Class IIa certification yet."


Middleware shapes (Go)

Composed as a stack on the chi router. auth package owns permissions; the middleware package owns tier entitlements / org entitlements / limits (see services/api/internal/core/middleware/tiers.go).

Aspirational — RequireTierEntitlement / RequireOrgEntitlement / EnforceLimit are not wired on any production route today. The example block below shows the canonical four-gate composition and lights up when the first tier-gated entitlement ships at Layer 7+ (telerehab / automations). RequirePermission and RequireSuperadmin are real and ship today; RequireTierEntitlement / RequireOrgEntitlement / EnforceLimit are deferred per Layer 1 reservation.

go
r.Route("/v1/automation-rules", func(r chi.Router) {
    r.Use(auth.RequirePermission("automations.manage"))
    r.Use(middleware.RequireTierEntitlement("automations"))
    r.Post("/", automationHandler.Create)
    r.Get("/", automationHandler.List)
})

r.Route("/v1/treatment-plans", func(r chi.Router) {
    r.Use(auth.RequirePermission("treatment_plans.manage"))
    r.Use(middleware.RequireTierEntitlement("treatment_plans"))
    r.Use(middleware.RequireOrgEntitlement("telerehab_enabled"))
    r.Post("/", treatmentPlanHandler.Create)
})

r.With(
    auth.RequirePermission("patients.onboard"),
    middleware.EnforceLimit("max_patients", 1),
).Post("/v1/patients", patientHandler.Create)

EnforceLimit is positional — the delta argument is what the request will consume if it succeeds. For variable-cost requests (storage upload, video minutes), the handler computes delta and calls middleware.EnforceLimitN(ctx, code, delta) inline rather than declaring it on the route.

Org-entitlement check — middleware vs inline

Two valid placements:

  • RequireOrgEntitlement("code") middleware on the route. Use when the entire route family is gated by an org entitlement (e.g. all /v1/treatment-plans/* requires telerehab_enabled).
  • Inline principalCtx.HasOrgEntitlement("code") inside the handler. Use when only some code paths within a handler are regulated, or when the gate has additional context.
go
func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
    if h.requestSelectsVideo(r) && !principal.Subject(r).HasOrgEntitlement("video_consultations_enabled") {
        httputil.WriteError(w, httputil.NewForbiddenError(
            "org_entitlement_disabled", "video_consultations_enabled"))
        return
    }
    // ... rest of handler
}

The org-entitlement surface is read identically in both cases — principalCtx.HasOrgEntitlement reads from a request-scoped cache populated by OrganizationContext middleware (which loads the org's organization_entitlements row once per request).

Subject extensions

The existing Subject (currently carries User, Org, Permissions, IsSuperadmin) gains:

go
type Subject struct {
    // ... existing fields ...

    OrgEntitlements   OrgEntitlementSet   // typed flags from organization_entitlements
    TierEntitlements  TierEntitlementSet  // resolved plan-entitlement unlocks across active subscriptions
    Limits            LimitSnapshot       // resolved caps for hot-path quotas
}

func (s *Subject) HasOrgEntitlement(code string) bool  { /* ... */ }
func (s *Subject) HasTierEntitlement(code string) bool { /* ... */ }
func (s *Subject) Limit(code string) LimitState        { /* ... */ }

HasTierEntitlement and HasOrgEntitlement return true for superadmin (consistent with HasPermission's superadmin shortcut). Limits do not auto-pass for superadmin — even superadmin requests count against meters and respect hard caps, because metering is operational, not authorization. Document this in code comments.


Elevated-session middlewares

Two foundation primitives sit alongside the four-gate model and gate routes on an active elevated session row (not a permission). Both are stateful (the session row is the source of truth) and both write a session-id GUC that flows into audit_log_insert's output column for forensic linkage. They share lazy-finalize semantics, similar shapes, and a runtime exclusion guard — but apply in different trust contexts.

RequireBreakGlass(scope, svc, paramName) chi.Middleware

Gates platform-staff routes that touch identifiable cross-tenant patient data. Implementation: Foundation 1B.11. Pattern: P15.

Composition order: mount AFTER RequireURLOrgMatchesScope(paramName) (P47) so the URL {paramName} is already validated. Place BEFORE the route handler. No RequirePermission needed — break-glass uses Go-only platform permissions checked inside the service's Open (not per-org RBAC).

Outcomes:

  • Active session matched → bind audit GUC (break_glass_id + action_context = 'break_glass'); attach session id to context via BreakGlassSessionIDFromContext; pass through.
  • Session expired (closed_at IS NULL but expires_at < NOW()) → service lazy-finalizes on admin pool (closed_at = expires_at, closed_by_principal_id = NULL); middleware returns 410 break_glass_expired so the frontend can prompt re-elevation.
  • No active session → 403 break_glass_required so the frontend can render the elevation modal.

Superadmins do NOT bypass this gate. Every cross-tenant patient-data access goes through an audited, justified, time-bound session — silent bypass for the most-privileged actor would defeat the audit trail.

RequireImpersonation(svc, paramName) chi.Middleware

Gates clinic-staff routes that perform actions on a patient's behalf (assisted form fill, accessibility help, troubleshooting). Implementation: Foundation 1B.13. Pattern: P16.

Composition order: mount AFTER RequireURLOrgMatchesScope(paramName) AND RequirePermission(principal.PermPatientsImpersonate) — the permission gate is the route-layer floor; the middleware enforces "an open session exists." RequirePermission and RequireImpersonation together (in that order) are the canonical stack for any future production consumer.

No scope argument. Unlike break-glass, impersonation has a single permission and the active-session uniqueness key is (staff_principal, organization) — there is at most one active session to match against, so no scope routing is needed.

Outcomes:

  • Active session matched → bind audit GUC (impersonation_id + action_context = 'impersonation'); attach session id to context via ImpersonationSessionIDFromContext; pass through.
  • Session expired → service lazy-finalizes; middleware returns 410 impersonation_expired.
  • No active session → 403 impersonation_required. The frontend reads this code to render the open-session form.

Foundation-tier note. No production consumer routes mount RequireImpersonation today; the middleware ships now so the contract is in place + the schema is exercised end-to-end via the open/close endpoints + integration tests. Consumers light up at F3 (forms) and F5 (appointments) when those features ship.

Cross-context exclusion (one elevated session at a time)

breakglass.Service.Open and impersonation.Service.Open enforce a runtime invariant: a single principal cannot have BOTH a break-glass session AND an impersonation session active for the same (principal × org) simultaneously. Either service rejects with 409 cross_context_active if the other table has a matching row.

Mechanically: each service's repository runs a small SQL probe against the other table on the admin pool before INSERTing its own session. The redefined audit_log_insert reads BOTH current_app_break_glass_id() and current_app_impersonation_id() unconditionally so a future legitimate compounding case (if the rule ever relaxes) writes both columns correctly without another schema change. Today the runtime guard means at most one of the two GUCs is ever non-NULL on a given request, and audit_log rows reflect that invariant.

Authorship semantics during impersonation (locked)

Mutations inside an active impersonation session attribute to the staff principal at BOTH the data layer and the audit layer. The audit row carries impersonation_id; consumers that want "who really did this" follow the link. There is no acting_as_patient_id GUC, no rebind helper, and no RLS policy that flips the calling principal during a session. This is a deliberate departure from the original spec's split-author model — see Foundation 1B.13 decisions for the reasoning. Layer 2+ features write actor = current_app_principal_id() always; impersonation is a transparency layer, not a context-rebinding mechanism.


Regulated boundary

The org-entitlement check is the only way regulated/clinical Go code reads tier-driven state. Concretely:

Allowed:

go
// Clinical handler, inside the regulated boundary
if !principalCtx.HasOrgEntitlement("treatment_plans_enabled") {
    return forbidden("org_entitlement_disabled", "treatment_plans")
}

Disallowed in regulated code:

go
// DO NOT do this in clinical/regulated code paths
if !principalCtx.HasTierEntitlement("treatment_plans") { /* ... */ }                    // ❌ reaches into the tier engine
if subscriptionRepo.OrgHasTierEntitlement(orgId, "treatment_plans") { /* ... */ }  // ❌ same

The tier engine writes organization_entitlements via SubscriptionService.RecomputeOrgEntitlements (see tiers-and-subscriptions.md). Writes are unidirectional: tier engine → org entitlements → clinical reads.

This rule has a single exception: billing/admin UI. Code that renders "your tier includes telerehab" or "upgrade to unlock telerehab" reads from tier entitlements (HasTierEntitlement), not from org entitlements. That UI is non-clinical — it lives outside the SaMD verification scope by design.

The boundary in one sentence: org entitlements answer "is this clinical action regulated-enabled for this org?"; tier entitlements answer "did this org pay for this SKU?". The first lives inside the regulated read surface; the second does not. Tier-engine projection makes the two consistent.


Why not collapse TierEntitlement and OrgEntitlement

If the platform never carries regulated entitlements in tiers, RequireTierEntitlement and RequireOrgEntitlement would be redundant. They are not redundant because:

  1. Some org entitlements are not tier-driven. Incident kill-switches, per-org regulatory exceptions, jurisdictional gates (e.g. a US clinic doesn't get pose_estimation_enabled until HIPAA review even on the Dedicated tier). These need a write path that doesn't go through tier logic.
  2. Some tier entitlements are not org entitlements. automations, webhooks, bulk_export, custom_domain — non-clinical, no IEC 62304 implication. Gating them by RequireOrgEntitlement would force every non-clinical SKU change through the regulated audit lane.
  3. Different audit cadence. Tier-entitlement changes are billing events (frequent). Org-entitlement changes are regulatory events (rare, sensitive, alerted on).
  4. Different error codes. 402 tier_entitlement_unavailable is a buy-button. 403 org_entitlement_disabled is "contact us." Conflating them produces wrong UX and wrong telemetry.

Keep them separate. The tier engine projection ensures the regulated subset of tier entitlements and the org entitlements stay in sync.


Why not collapse Permission and TierEntitlement

This is the more common confusion. They look similar — both gate a request based on a per-org config. But:

  • Permission is per-principal-role. It answers who can do this. Different principals in the same org get different answers.
  • TierEntitlement is per-org. It answers whether this org has the SKU. Every user in the org gets the same answer.

A specialist with automations.manage permission cannot create automations if the org is on Free tier (no automations tier entitlement). An admin without automations.manage permission cannot either, even on Pro tier (no permission). Both gates must pass; they are independent.

Collapsing them would mean either:

  • Treating tier entitlements as permissions, which means automations.manage has to be re-granted to every role on every tier upgrade. The permission catalog would balloon.
  • Treating permissions as tier entitlements, which means RBAC becomes tier-driven. Custom per-org roles would have to ship with tier metadata. Dedicated-tier clinics would lose the ability to define their own role bundles.

Both are bad. Permission and tier entitlement stay independent. The middleware composition is the integration point.


Composition examples

Example 1 — Specialist creating a treatment plan on Pro tier

GateStateResult
Permissiontreatment_plans.manage granted to specialist role ✅pass
TierEntitlementtreatment_plans in active Pro subscription ✅pass
OrgEntitlementtreatment_plans_enabled = TRUE (projected from tier entitlement) ✅pass
Limitmax_active_treatment_plans 50/100 ✅pass + meter

Result: 200 OK.

Example 2 — Admin trying to enable automations on Free tier

GateStateResult
Permissionautomations.manage granted to admin role ✅pass
TierEntitlementautomations not in Free's organization_subscription_entitlements402 tier_entitlement_unavailable with upgrade URL

OrgEntitlement and limit are not evaluated. Body discriminates as tier_entitlement_unavailable.

Example 3 — Customer support trying to delete a patient

GateStateResult
Permissionpatients.delete not granted to customer_support role ❌403 permission_denied

No further gates. Body discriminates as permission_denied.

Example 4 — Admin onboarding patient #1001 on a 1000-cap tier

GateStateResult
Permissionpatients.onboard granted to admin ✅pass
TierEntitlementpatients in any tier (always) ✅pass
OrgEntitlementnot relevant for non-clinical actionn/a
Limitmax_patients is hard_block, current 1000, cap 1000, delta 1 ❌402 limit_exceeded with upgrade URL

Example 5 — Specialist on Pro starting a video call after the platform has revoked the org's video_consultations_enabled org entitlement for a regulatory issue

GateStateResult
Permissionappointments.create granted ✅pass
TierEntitlementvideo_consultations in Pro ✅pass
OrgEntitlementvideo_consultations_enabled = FALSE (manual superadmin override) ❌403 org_entitlement_disabled

Body discriminates as org_entitlement_disabled. The org is paying for the SKU (tier entitlement true) but the regulatory boundary is closed (org entitlement false). No upgrade URL — the fix is platform-side.

Example 6 — Admin on Pro + addon_telerehab who lost the addon (downgrade, but treatment_plans still in use)

GateStateResult
Permissiontreatment_plans.managepass
TierEntitlementtreatment_plans not in Pro alone, addon expired ❌402 tier_entitlement_unavailable

Existing treatment_plans remain in the database (CLAUDE.md soft-delete rule) but new ones cannot be created. OrgEntitlement is also FALSE after the next projection runs — clinical reads will fail closed regardless of tier-entitlement state.


Layer 1 reservation

What ships at Layer 1 vs deferred:

Lands at Layer 1:

  • The mental model and conventions in this doc (no code yet).
  • RequirePermission middleware (already shipped).
  • Subject.OrgEntitlements field populated from organization_entitlements row in OrganizationContext middleware. principalCtx.HasOrgEntitlement(code) helper.
  • current_app_has_org_entitlement(entitlement_code TEXT) SQL function (RLS-callable).
  • The error response shape and discriminators above. Add to httputil constructors: NewTierEntitlementUnavailableError, NewOrgEntitlementDisabledError, NewLimitExceededError. Each returns the right status + body.

Deferred (later layers):

  • RequireTierEntitlement(code) middleware. Ships with the first tier-entitlement-gated route (likely automations or webhooks in F7 — Layer 8).
  • RequireOrgEntitlement(code) middleware sugar (inline principalCtx.HasOrgEntitlement works without it). Ships when the first regulated route family lands (F9 — telerehab, Layer 10).
  • EnforceLimit(code, delta) middleware. Ships with the first limited resource (likely max_patients once tier/subscription enforcement lands in F2 — Layer 3).
  • Subject.TierEntitlements and Subject.Limits fields. Populated from active subscriptions when the resolver function exists. Until then, HasTierEntitlement always returns true (no tier gates exist yet, so nothing should be blocked) and Limit returns "unlimited" — both behaviors are documented in code with TODO pointing to this doc.

Why ship the model now even without enforcement:

  • The SQL function current_app_has_org_entitlement and the Subject.OrgEntitlements field unblock RLS policies and clinical-code patterns that will land in Layer 2+. Without them, every clinical RLS policy ships with a placeholder that has to be retrofitted.
  • The error constructors and discriminator shape are tiny but require alignment with frontend error handling. Ship the shape now so frontend doesn't pin to a different one.
  • The middleware composition rule is documented now so feature work in Layer 2+ doesn't collapse permission / tier-entitlement / org-entitlement / limit by accident.

Open questions

QuestionDecided in
EnforceLimit for soft-metered resources — emit the usage_event synchronously inside the middleware, or asynchronously after the handler succeeds? Affects double-count semantics on handler errors.First soft-metered limit
Header-based metering disclosure (X-RateLimit-* on success) — opt-in per route or default-on for any limited resource?First limited route
Org-entitlement cache TTL — OrganizationContext reads organization_entitlements per request; should it cache across requests in Redis with invalidation on entitlement writes? Hot path for clinical code.When clinical traffic load is real
EnforceLimit ordering for multi-resource handlers (e.g. one request bumps two meters) — atomic both-or-neither, or first-wins?First multi-resource handler