Skip to content

Tiers, Subscriptions & Patient Tiers

The platform's commercial model: tiers the platform sells to clinics (org-side), and tiers the clinic offers patients (per-org). Companion to org-settings.md (where the current-tier pointer lives), middleware-composition.md (how RequireTierEntitlement / EnforceLimit compose with RequirePermission), and data-model.md (entity catalog).

Why this exists. Tiers gate entitlements and quotas; they do not grant permissions. RBAC is the authorization primitive; tiers pick which entitlements are available and how much capacity an org has. The two systems compose without overlapping. This doc fixes the schema, the resolution rules, and the regulatory boundary so future features inherit a consistent model instead of inventing one.


Core principles

  1. Tiers never grant permissions. Tiers select entitlements and limits; RBAC decides who can do what. A tier flips a flag (automations enabled), an org admin grants a role the automations.manage permission. The two are orthogonal and stay orthogonal.
  2. Tiers never gate clinical code directly. Regulated code reads only from organization_entitlements. The tier engine projects regulated entitlements onto that surface; the regulated code is decoupled from the tier engine. See Entitlement projection.
  3. Snapshot on subscribe. Entitlements and limits are frozen onto the subscription at signup (P14b / P18). Tier edits affect new signups only; existing subscribers are grandfathered until they renew or actively switch.
  4. Multi-subscription stacking. An org can hold one base tier plus N add-ons plus M usage packs simultaneously. Effective entitlements and limits are resolved by combining all active subscriptions.
  5. Per-subscription overrides. Sales motion can grant exceptions on top of the snapshot — recorded as audited rows, not direct edits to the snapshot.
  6. No hardcoded tier strings. Tiers are referenced by tier_id (UUID) or code (TEXT) only as catalog lookups. The only hardcoded tier-shaped string in the codebase is in the platform-level catalog migration that seeds the initial tiers. Application code never compares against tier codes.
  7. Tiers differentiate on seats, features and allowances — never on clinical volume. Nothing on the path between a patient and their care may be capped: not patients, not appointments, not forms, not documents. See What tiers differentiate on.
  8. A cap may block only at a moment an admin chose. Hard-block is legitimate when someone deliberately adds a seat, a location or an integration, because an upgrade prompt is a sensible answer to a deliberate act. It is never legitimate in response to something a patient triggered.
  9. Patient tiers are per-org and never flip RBAC. Each clinic defines its own tier catalog. Tier perks ride entirely on parallel snapshot tables (patient_tier_entitlements, patient_tier_limits, patient_subscription_entitlements, patient_subscription_limits) that share the entitlements / limit_definitions catalogs with the org-side billing engine. Patients have no role at the org — portal access comes from the existence of a patients row, never from a permission grant. See decisions.md → Why patients are not memberships, and patient tiers are not roles.

Concept map

Four orthogonal concerns at the request gate:

ConceptQuestionWhere definedWhere enforcedDoc
PermissionIs the verb allowed for this caller?permissions (catalog) + roles/role_permissionsRequirePermission middleware + RLSrbac-permissions.md
TierEntitlementIs this entitlement included in the org's SKU?entitlements (catalog) + tier_entitlementsorganization_subscription_entitlementsRequireTierEntitlement middlewarethis doc
OrgEntitlementIs this regulated boundary open for this org? Superadmin-controlled — no plan upgrade opens oneorganization_entitlements (typed columns)RequireOrgEntitlement / current_app_has_org_entitlementorg-settings.md + this doc
LimitIs the meter under cap for this resource?limit_definitions (catalog) + tier_limitsorganization_subscription_limitsEnforceLimit middleware + usage_quotasthis doc

A request that reaches a clinical write must clear all four. See middleware-composition.md for ordering and error codes.

One of these four gates is still a no-op in the code (verified 2026-08-11)

TierEntitlement is now live (2026-08-11). Subject.HasTierEntitlement was return true for every code — a Layer 1 stub that was never swapped even after RequireTierEntitlement("custom_domain") landed, so the platform's only tier gate passed for every org on every tier. It now reads Subject.PlanEntitlements, populated per request by subscriptions.Repository.LoadPlanEntitlements (active subscription snapshots, with overrides applied on top). Absent code or absent map denies.

Consequence to plan for: every org starts on free, and free has no tier_entitlements rows. So any org that was never explicitly subscribed to pro or dedicated now fails custom_domain. Only POST /domains is gated — list, verify and delete stay permission-only, and public hostname resolution is untouched, so domains already in place keep working. A clinic that should keep the feature needs a pro subscription or an entitlement override before this reaches its environment.

EnforceLimit still cannot block on any lifetime cap. usage_quotas.period is CHECK (period IN ('day','week','month')) and metering.Registry.Register panics on anything else, so a period_kind = 'lifetime' code can never have a counter row; LoadLimits skips unregistered codes and leaves Current = 0 permanently. max_patients, max_specialists, max_storage_bytes and max_webhook_subscriptions are all lifetime.

OrgEntitlement was mounted on ZERO routes until 2026-08-11 — the four regulated flags on organization_entitlements were loaded into Subject on every request and read by nothing, so a compliance surface claimed boundaries it did not enforce. video_consultations is now gated on both the staff and patient join routes; telerehab, treatment_plans and pose_estimation still gate nothing.

Mounting the rest needs the grandfather script first. The org-creation trigger inserts organization_entitlements with every flag FALSE, and no tier grants a regulated entitlement, so every organization on every environment has all four off. Mounting a gate without infra/scripts/000003-grandfather-org-entitlements.sql switches the feature off for every clinic mid-treatment. New orgs are NOT covered by that script — the trigger still defaults to FALSE, so onboarding has to enable them, and which flags a new clinic starts with is an open product decision.

And behind all of it: no capability writes a usage row on a deployed environment. The metered email channel is wired but only for BillingScopeTenant categories, and every seeded category is BillingScopePlatform; video meters work wherever 000049 runs, which today is local only.


What tiers differentiate on

The question this section settles: if a cap can't block the clinical path, what is a tier for?

What a clinic actually costs the platform

Marginal cost per clinic is dominated by one line and is otherwise close to flat:

LineScales withMateriality
Video participant-minutesTelerehab consultations heldThe only material one. Billed per participant-minute by the provider; a busy telerehab clinic costs real money, an in-person-only clinic costs nothing.
Streaming bandwidth (session playback)Patient activity, not staff countReal at volume.
Storage, transactional emailUploads, notificationsRounding errors at clinic scale.
Compute, database, RedisShared fleetEffectively fixed. A clinic's rows and queries are marginal until much larger scale.

Seat count is not on this list, and it is not going to be. Five specialists and ten specialists cost the platform the same; a specialists row is a few hundred bytes on shared infrastructure. Any argument that seat caps recover cost is wrong on the numbers.

Why seats are still the right lever

Seat pricing here is value pricing, not cost recovery, and that is a legitimate and deliberate choice. A ten-practitioner clinic extracts roughly five times the value from the platform as a two-practitioner one, and the seat count is the cleanest observable proxy for that. The justification is willingness to pay, not marginal cost, and the docs should say so rather than implying an infrastructure story that does not survive arithmetic.

Pricing purely on cost would mean charging for video minutes and nothing else, which prices a physical-only clinic at zero.

A seat is a login, never a roster row

Cap organization_memberships held by human principals. Never cap specialists.

A specialist row may be calendar-only (human_id IS NULL, no login) — the shape a clinic uses to model treatment rooms, equipment, or a visiting practitioner who never signs in. A clinic with two staff logins and six calendar-only entries would trip a "2 specialists" cap while having exactly two users, and the fix a clinic would reach for is to stop modelling its own rooms.

max_specialists therefore stays in the catalog as informational and is not enforced. Seat enforcement belongs on a separate code counted at the membership-create / invite endpoint.

The three axes

AxisMechanismBehaviorExamples
Seatsmax_staff_seats, counted at the invitation endpointhard_blockStaff login seats
FeaturesRequireTierEntitlement + organization_entitlementsRoute absent / 402Custom domain, webhooks, automations, bulk export, AI assistant
AllowancesMetered — usage_records + usage_quotassoft_meter, invoice or upsell on overageVideo minutes, emails, storage

Features are the highest-leverage axis and the cheapest to make real: the catalog, the snapshot tables and the middleware already exist, and gating a feature cannot interrupt a consultation in progress.

What may block, and what may never

The rule is about who initiated the action, not how expensive it is:

Blocks (hard_block)Never blocks (soft_meter / informational)
Adding a staff login seatRegistering a patient
Adding a locationBooking, rescheduling or completing an appointment
Adding a custom domain, webhook subscription or integrationFilling in or signing a form
Generating a document
Sending a transactional email
Starting or joining a video consultation

Everything in the left column is a deliberate administrative act, performed by someone who can act on an upgrade prompt, with nothing in flight that breaks. Everything in the right column is triggered by a patient or by a clinician mid-care, where a 402 means turning a person away from treatment because of a billing state.

This generalises the reasoning 000049 already applies to video minutes: refusing a room because a monthly quota tripped means refusing a consultation a patient showed up for. The same sentence is true of a patient standing at the front desk, and of an appointment reminder that never sends.

What shipped (2026-08-11)

000004's seed was edited in place and carried onto live databases by infra/scripts/000004-limit-doctrine.sql:

CodeWasNowWhy
max_patientshard_blocksoft_meterThe front desk must be able to register the patient standing in front of it
emails_sent_per_monthhard_blocksoft_meterA refused reminder fails a patient, not an administrator
max_storage_byteshard_blocksoft_meterAttaching a scan to a consultation is care in progress
max_specialistshard_blockinformationalThe roster is not the seat count — see above
max_staff_seatshard_blockNew. Free 2 / Pro 10 / Dedicated unlimited

organization_subscription_limits freezes behavior at subscribe time and LoadLimits reads the snapshot, not the catalog — so editing limit_definitions alone would have left every existing clinic on hard_block. The catch-up rewrites live snapshots too, and that is not a general grandfathering exception: it applies because those four flips only ever REMOVE a refusal, so no clinic can be worse off. max_staff_seats goes the other way and is not backfilled silently — the script reports which orgs would land over cap and leaves the backfill commented out. Until it runs, an existing org carries no seat snapshot, Cap is nil, and the endpoint does not gate it; new orgs get the cap from the provisioning trigger. That is a safe intermediate state, not a broken one.

Seats are counted, not metered. max_staff_seats is lifetime, so no usage_quotas row can exist for it and EnforceLimit would read Current = 0 forever. invites.Service.CreateStaff counts human-held organization_memberships plus outstanding staff invitations — an issued invitation is a claimed seat, and counting memberships alone would move the refusal from the admin who can act on it to a colleague who cannot. The check runs after the duplicate guards, because re-inviting someone who already holds a seat consumes none.

Over-allowance, not over-cap

For the allowance axis the commercial answer to exceeding a soft cap is an invoice line or an upgrade conversation, not a refusal. That requires usage to be visible to the clinic before it is billed — which is what the Organization → Usage tab exists for. A soft cap nobody can see is indistinguishable from no cap at all.


Catalog tables (platform-wide, no organization_id)

Catalog tables are managed via migrations, not at runtime. They are global; superadmin-only writes via AdminPool.

tiers

The platform's product catalog. Versioned for snapshot (P18).

ColumnTypeNotes
idUUID PK
codeTEXT NOT NULL UNIQUEe.g. free, pro, dedicated, addon_telerehab, pack_video_minutes_1000. Stable, used by migrations and external billing system.
nameTEXT NOT NULLDisplay name.
descriptionTEXT
kindTEXT NOT NULL`base
billing_cycleTEXT NULL`monthly
base_priceDECIMAL(10,2) NULLInformational; canonical price comes from the external billing system once wired.
currencyTEXT NOT NULL DEFAULT 'RON'(P22)
is_publicBOOLEAN NOT NULL DEFAULT FALSETRUE ⇒ appears in self-service signup. FALSE ⇒ sales-only, custom tiers.
versionINT NOT NULL DEFAULT 1Bumped on any entitlement/limit edit.
publishedBOOLEAN NOT NULL DEFAULT FALSEOnly published versions can be subscribed to.
published_atTIMESTAMPTZ NULL
deprecated_atTIMESTAMPTZ NULLWhen set, prevents new signups; existing subscribers continue.
translationsJSONB NOT NULL DEFAULT '{}'(P21) — for name/description localization.
created_at, updated_atTIMESTAMPTZ

RLS. SELECT for everyone (read-only catalog). UPDATE/INSERT/DELETE: superadmin via AdminPool only.

tier_versions

Append-only history of tier + entitlements + limits. Snapshotted onto organization_subscriptions at subscribe time.

ColumnTypeNotes
idUUID PK
tier_idUUID FK
versionINT NOT NULLMatches tiers.version at the moment of publish.
published_atTIMESTAMPTZ NOT NULL
entitlements_snapshotJSONB NOT NULLArray of entitlement codes enabled at this version.
limits_snapshotJSONB NOT NULLArray of {code, cap_value, behavior}.
metadata_snapshotJSONB NOT NULLFrozen {name, description, base_price, currency, billing_cycle}.
changed_by_principal_idUUID FK NULL → principals(id)Superadmin (human) who published this version. Human-only constraint enforced by platform_memberships, not by a CHECK here.
created_atTIMESTAMPTZ
Unique(tier_id, version)

Snapshot semantics follow P14a (append-only): once a version row is inserted, it is immutable. Editing the catalog row produces a new version row. (P14b — immutable-after-state-transition — applies to forms / signed PDFs, not to tier versions.)

entitlements

Catalog of every tier-gated entitlement. Mirrors the permissions catalog in shape — every entitlement code is documented in this table, seeded by the migration that introduces it. Natural-key PK.

ColumnTypeNotes
codeTEXT PKe.g. custom_domain, automations, webhooks, bulk_export, api_access, treatment_plans, video_consultations, pose_estimation.
nameTEXT NOT NULLDisplay name for billing UI.
descriptionTEXT
regulatedBOOLEAN NOT NULL DEFAULT FALSETRUE ⇒ entitlement must be projected onto organization_entitlements when active. See Entitlement projection.
entitlement_columnTEXT NULLName of the typed column on organization_entitlements this entitlement projects to; required when regulated = TRUE, must be NULL otherwise (enforced by chk_entitlements_entitlement_column_consistency CHECK).
created_at, updated_atTIMESTAMPTZ

RLS. SELECT for everyone. Writes superadmin-only via AdminPool.

limit_definitions

Catalog of every metered or capped resource.

ColumnTypeNotes
idUUID PK
codeTEXT NOT NULL UNIQUEe.g. max_patients, max_specialists, max_storage_bytes, max_active_treatment_plans, video_minutes_per_month.
nameTEXT NOT NULL
descriptionTEXT
unitTEXT NOT NULL`count
default_behaviorTEXT NOT NULL`hard_block
period_kindTEXT NOT NULL`lifetime
created_at, updated_atTIMESTAMPTZ

RLS. SELECT for everyone. Writes superadmin-only.

tier_entitlements

Which entitlements a tier unlocks.

ColumnTypeNotes
tier_idUUID FK
entitlement_codeTEXT NOT NULL FK → entitlements(code)Reference by code (not id) for migration ergonomics.
enabledBOOLEAN NOT NULL DEFAULT TRUEAllows a tier version to explicitly disable an entitlement without removing the row (audit trail across versions).
created_atTIMESTAMPTZ
PK(tier_id, entitlement_code)

tier_limits

What caps and meter behaviors a tier sets.

ColumnTypeNotes
tier_idUUID FK
limit_codeTEXT NOT NULL FK → limit_definitions(code)
cap_valueBIGINT NULLNumeric cap in the unit defined by limit_definitions.unit. NULL ⇒ unlimited (e.g. dedicated-mode tiers).
behavior_overrideTEXT NULLOverride limit_definitions.default_behavior if this tier should treat the resource differently. NULL ⇒ inherit.
created_atTIMESTAMPTZ
PK(tier_id, limit_code)

Tier kinds

tiers.kind is one of:

  • base — the org's primary subscription. Exactly one base subscription is active per org at a time (service-layer invariant — checked on every subscription insert; not a DB constraint because past inactive base subscriptions are valid history).
  • addon — adds entitlements/limits on top of a base. An org can hold N concurrent add-ons. Example: addon_telerehab unlocks the regulated treatment_plans entitlement without changing the base tier.
  • usage_pack — a one-time purchase that increases the cap of one or more metered limits. Example: pack_video_minutes_1000 adds 1000 to the video_minutes_per_month cap for the current period. Usage packs are not subscriptions in the renewal sense — they expire when consumed or at period end.

The kind drives resolution rules — entitlements and quota caps are MAX across active subscriptions; usage-pack caps are SUM.


Subscription tables (per-org)

organization_subscriptions

The N:M between an org and the tiers it holds. One row per active tier attachment.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
tier_idUUID FKPointer to the catalog tier.
tier_versionINT NOT NULLThe tier_versions.version snapshotted at subscribe time.
statusTEXT NOT NULL`trialing
started_atTIMESTAMPTZ NOT NULL
current_period_starts_atTIMESTAMPTZ NULLNULL for usage_pack (no period).
current_period_ends_atTIMESTAMPTZ NULL
cancel_atTIMESTAMPTZ NULLScheduled cancel — at period end if scheduled, immediate if NULL.
canceled_atTIMESTAMPTZ NULL
payment_providerTEXT NOT NULL DEFAULT 'manual'`manual
external_subscription_idTEXT NULLe.g. Stripe subscription ID. NULL until billing is wired.
created_at, updated_atTIMESTAMPTZ
Index(organization_id, status)Hot path: "active subscriptions for org X" on every gated request.

RLS.

  • SELECT: callers with subscriptions.view_org (granted to admin template).
  • INSERT/UPDATE/DELETE: callers with subscriptions.manage (granted to admin template). The tier engine writes via the AppPool with the org admin's session — billing webhooks acquire a service-account context. Superadmin via AdminPool can write any org.

State machine.

        ┌────────────┐  start_trial  ┌──────────┐
        │ trialing   │──────────────▶│ active   │
        └────┬───────┘               └────┬─────┘
             │                            │
             │ trial_expired              │ payment_failed
             ▼                            ▼
        ┌──────────┐                  ┌──────────┐
        │ expired  │                  │ past_due │
        └──────────┘                  └────┬─────┘
                                           │ payment_recovered → active
                                           │ dunning_exceeded → canceled

                                       ┌──────────┐
                                       │ canceled │
                                       └──────────┘

paused is a manual admin pause (trip-out without canceling). Transitions are validated in the service layer per P33.

organization_subscription_entitlements (snapshot)

Frozen at subscribe time. Tier edits never modify these rows.

ColumnTypeNotes
subscription_idUUID FK
entitlement_codeTEXT NOT NULL
enabledBOOLEAN NOT NULL
created_atTIMESTAMPTZ
PK(subscription_id, entitlement_code)

RLS. Inherits from organization_subscriptions access.

organization_subscription_limits (snapshot)

Frozen at subscribe time.

ColumnTypeNotes
subscription_idUUID FK
limit_codeTEXT NOT NULL
cap_valueBIGINT NULLNULL = unlimited.
behaviorTEXT NOT NULLResolved from tier_limits.behavior_override ?? limit_definitions.default_behavior.
created_atTIMESTAMPTZ
PK(subscription_id, limit_code)

organization_subscription_overrides

Sales-granted exceptions on top of the snapshot. Audited.

ColumnTypeNotes
idUUID PK
subscription_idUUID FK
override_kindTEXT NOT NULL`entitlement
entitlement_codeTEXT NULLRequired when override_kind = 'entitlement'.
entitlement_enabledBOOLEAN NULLRequired when override_kind = 'entitlement'.
limit_codeTEXT NULLRequired when override_kind = 'limit'.
cap_valueBIGINT NULLNULL = unlimited (override).
behavior_overrideTEXT NULL
granted_by_principal_idUUID FK → principals(id)Superadmin (human) who granted. Human-only constraint enforced by platform_memberships, not by a CHECK here.
reasonTEXT NOT NULLAudit trail.
effective_fromTIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_atTIMESTAMPTZ NULLNULL = until subscription ends.
revoked_atTIMESTAMPTZ NULL
revoked_by_principal_idUUID FK NULL → principals(id)
created_atTIMESTAMPTZ
CHECKchk_sub_override_shape — when override_kind = 'entitlement', entitlement_code + entitlement_enabled are NOT NULL and the limit-side columns are NULL; when override_kind = 'limit', limit_code is NOT NULL and the entitlement-side columns are NULL.

RLS. SELECT for subscriptions.view_org. INSERT/UPDATE: superadmin only. The clinic admin cannot grant their own overrides.


Resolution rules

When a request reaches RequireTierEntitlement or EnforceLimit, the resolver runs against the org's active subscriptions.

Tier-entitlement resolution

For each active subscription S of org O (status IN ('trialing','active','paused')):
    For each organization_subscription_entitlements row where enabled = TRUE:
        entitlement is unlocked
For each organization_subscription_overrides row where override_kind='entitlement' and active and not revoked and not expired:
    if entitlement_enabled = TRUE: entitlement is unlocked
    if entitlement_enabled = FALSE: entitlement is locked (override > snapshot)

Tier-entitlement unlock semantics: any active subscription that includes the entitlement unlocks it. A false override locks regardless of subscriptions. The cached resolution is invalidated when organization_subscription_entitlements or organization_subscription_overrides change for the org.

Limit resolution

Behavior-aware:

Caps SUM across active subscriptions. Overrides REPLACE.

  • cap_value = SUM(snapshot caps across active subscriptions), with a single NULL propagating as unlimited.
    • An add-on carrying 500 patients on top of Free's 50 gives 550, not 500. Same for minutes and every other unit: what you buy is added to what you had.
    • This corrected a MAX(...) rule the doc carried until 2026-08-11, which the implementation never followed — mergeLimits has always summed. Settled by the platform owner in favour of SUM, so the code was right; the doc moved. The old rule also drew a distinction between snapshot caps and usage_pack additions that nothing in the code ever made, and that nobody wanted: a pack and an add-on both add.
  • Overrides replace outright, cap and behavior together, and are applied last. "200 patients instead of 50" is what granting one means — an override that could only ever raise a number would not be an override. Applied in LoadLimits since 2026-08-11; before that, limit overrides were written, audited, displayed in Console, and read by nothing.

behavior resolution: if any active subscription says hard_block, the limit is hard. Otherwise soft_meter. An active override replaces the behavior along with the cap.

Org-entitlement resolution

Org-entitlements are read from organization_entitlements directly. The tier engine writes that table; the resolver here is not in the regulated read path. See Entitlement projection.


Entitlement projection

The regulatory boundary. The tier engine writes organization_entitlements; clinical/regulated code reads only from organization_entitlements. Clinical code never reads organization_subscription_entitlements or tier_entitlements directly. This is the load-bearing rule of middleware-composition.md.

Trigger

SubscriptionService.RecomputeOrgEntitlements(orgId) runs:

  • After any insert/update/delete on organization_subscriptions for orgId.
  • After any insert/update/delete on organization_subscription_overrides for any subscription of orgId.
  • On startup for any org with stale projection state (operational safety net).

Logic

entitlements_to_set = {}
For each regulated entitlement F (where entitlements.regulated = TRUE):
    if tier_entitlement_resolution(orgId, F.code) == enabled:
        entitlements_to_set[F.entitlement_column] = TRUE
    else:
        entitlements_to_set[F.entitlement_column] = FALSE
UPDATE organization_entitlements SET ... WHERE organization_id = orgId
INSERT INTO audit_log (action='UPDATE', entity_type='organization_entitlements', changes=diff, action_context='org_entitlement_change', ...)

The mapping entitlement.code → entitlement_column → organization_entitlements.<col> is data-driven via the entitlements.entitlement_column column. The CHECK constraint chk_entitlements_entitlement_column_consistency (regulated ⇔ entitlement_column NOT NULL) means a regulated entitlement cannot be added to the catalog without specifying its target entitlement column — fail-loud at migration time, no chance of a regulated entitlement silently projecting nowhere. When entitlement_column is NULL, the entitlement is non-regulated and never projects.

Concrete entrypoints:

  • Projection (write side): subscriptions.Service.RecomputeOrgEntitlements(orgID) (services/api/internal/core/domain/subscriptions/service.go) selects code, entitlement_column FROM entitlements WHERE regulated = TRUE, resolves each against the org's active subscriptions/overrides, and writes the resulting boolean into the named column on organization_entitlements. Called from every subscription mutation path (Create, UpdateStatus, GrantOverride, RevokeOverride).
  • Read (read side): Subject.HasOrgEntitlement(code) (services/api/internal/core/principal/subject.go) — code is an entitlements.code value; the lookup map is hydrated from organization_entitlements when OrganizationContext runs, so handlers and middleware never touch the column-name layer directly.

Why this shape

If clinical code read organization_subscription_entitlements directly, the tier engine, the subscription model, and RequireTierEntitlement middleware would all sit inside the SaMD verification scope (IEC 62304). Every tier-pricing change, every billing-state machine edit, would trigger regulatory re-validation. Decoupling via organization_entitlements confines the regulated read surface to a small, stable, typed table. The tier engine is non-clinical infrastructure that writes to that surface — its V&V scope is no broader than any other internal config writer.

Manual override

Superadmin can write organization_entitlements directly via Console UI without going through the tier engine. Use cases: incident kill-switch (an entitlement is regulated-disabled platform-wide; per-org disable for an audit), pre-tier-engine state (Layer 1 ships before the tier engine; entitlements are entirely manual until the engine wires up), per-org regulatory exception (e.g. a US clinic can't have pose_estimation_enabled without HIPAA review).

The tier engine's projection is idempotent and convergent — if a superadmin manually disables an entitlement that the tier engine would re-enable, the next projection re-enables it. To keep a manual disable persistent, either remove the regulated entitlement from the org's tier/overrides, or implement a "manual lock" flag on organization_entitlements (deferred — see Open questions).


Patient tiers

Tiers are per-clinic offerings. Each clinic defines its own catalog. Tiers never grant roles. Subscribing to a tier never mutates organization_memberships — patients have no role at the org post-1.26. Tier perks ride on parallel patient_tier_entitlements / patient_tier_limits tables that share the entitlements / limit_definitions catalogs with the org-side billing engine; the snapshot-on-subscribe pattern (P37) projects them onto patient_subscription_entitlements / patient_subscription_limits at subscribe time.

patient_tiers

Per-org catalog of tiers the clinic offers. Mirror of tiers — versioned (P18), publishable, with parallel patient_tier_entitlements and patient_tier_limits junction tables.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
codeTEXT NOT NULLOrg-defined, e.g. basic, premium.
nameTEXT NOT NULL
descriptionTEXT
is_activeBOOLEAN DEFAULT TRUE
is_defaultBOOLEAN DEFAULT FALSEExactly one tier per org should have is_default = TRUE; that's the tier auto-assigned to new sign-ups. Partial unique index WHERE is_default = TRUE enforces this at the DB level.
versionINT NOT NULL DEFAULT 1
publishedBOOLEAN NOT NULL DEFAULT FALSE
published_atTIMESTAMPTZ NULL
sort_orderINT DEFAULT 0
external_price_hintDECIMAL(10,2) NULLInformational only. Source of truth for pricing is the clinic's own billing system.
currencyTEXT DEFAULT 'RON'
created_at, updated_atTIMESTAMPTZ
Unique(organization_id, code)

Companion tables (1.21 / 1.26). patient_tier_versions (append-only history, mirror of tier_versions), patient_tier_entitlements(tier_id, entitlement_code, enabled), patient_tier_limits(tier_id, limit_code, cap_value, behavior_override) — see data-model.md Area 16 for the full shape.

RLS.

  • SELECT: members of the org.
  • INSERT/UPDATE/DELETE: callers with patient_tiers.manage (granted to admin template).

patient_subscriptions

Per-org per-patient subscription to a tier.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
patient_idUUID FKReferences patients.id (per-org), not patient_profiles — subscriptions are per-clinic.
tier_idUUID FK
statusTEXT NOT NULL`trialing
started_atTIMESTAMPTZ NOT NULLWhen the subscription first began (does not change across renewals).
current_period_starts_atTIMESTAMPTZ NULLStart of the current billing/grant period. Advances on each renewal. NULL for tiers without a recurring period (one-shot tiers).
current_period_ends_atTIMESTAMPTZ NULLEnd of the current period. Drives the tier-inclusions rollover hook.
expires_atTIMESTAMPTZ NULLHard expiry of the whole subscription regardless of renewal. NULL = renews indefinitely until canceled.
canceled_atTIMESTAMPTZ NULL
payment_providerTEXT NOT NULL DEFAULT 'external'`external
external_subscription_idTEXT NULLSet when payment_provider != 'external'.
created_at, updated_atTIMESTAMPTZ
Index(patient_id, status)One active subscription per patient is the service-layer invariant.

RLS.

  • SELECT: members of the org with patient_subscriptions.view_org. Patients see their own via current_human_patient_profile_ids() join through patients.patient_profile_id.
  • INSERT/UPDATE/DELETE: callers with patient_subscriptions.manage (granted to admin template; clinic's admin or customer_support is who flips tiers in the out-of-band model).

Snapshot tables. patient_subscription_entitlements and patient_subscription_limits are populated at subscribe time from patient_tier_entitlements and patient_tier_limits (the active version's snapshot). Tier edits never modify subscriber rows. patient_subscription_overrides carries clinic-granted exceptions (gated by patient_subscriptions.manage, not platform admin — clinics, not the platform, grant patient-side overrides).

patient_tier_inclusions — counted entitlements per tier

A tier can do more than grant role-based access — it can also bundle counted entitlements that auto-replenish per period. "Annual Premium includes 4 free consultations and 4 specialist-assigned treatment plans" is a tier inclusion.

Design choice: delegate to service_plans, do not invent a parallel counter. service_plans + patient_service_plans (in Service Catalog, Area 3) already track session counts, expiry, status, and consumption-on-booking. A tier inclusion is a row that says "when a patient subscribes to this tier, auto-create one or more patient_service_plans rows from the named template; revoke them when the tier ends." Existing booking-against-plan logic doesn't change — it sees a patient_service_plan and consumes from it regardless of how it came into existence.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
tier_idUUID FK → patient_tiers(id)
service_plan_idUUID FK → service_plans(id)The template to clone into patient_service_plans when a tier subscription becomes active.
grant_periodTEXT NOT NULL`per_subscription_period
grant_quantityINT NOT NULL DEFAULT 1Usually 1 (one cloned patient_service_plan per inclusion). >1 if a tier intentionally grants multiple separate enrollments of the same template.
carry_over_unusedBOOLEAN NOT NULL DEFAULT FALSEWhen a per-period grant rolls into a new period, do unused sessions carry forward (TRUE) or drop (FALSE). Default FALSE ("use it or lose it") — clearest accounting; per-row override allowed for products that explicitly market rollover.
prorate_on_upgradeBOOLEAN NOT NULL DEFAULT FALSEWhen a patient upgrades to this tier mid-period, is the inclusion granted in full (FALSE) or pro-rated to the remaining period (TRUE). Default FALSE (full grant) — common sales-lever practice; per-row override allowed for inclusions where proration matters more than UX.
created_at, updated_atTIMESTAMPTZ
Index(tier_id)Hot path for resolving "what does this tier include?" on every subscription state change.

RLS. SELECT for members of the org. INSERT/UPDATE/DELETE: callers with patient_tiers.manage — same permission gating the tier itself, since inclusions are part of the tier definition.

Tier → entitlement provisioning

The companion hook to role binding. Runs on the same patient_subscriptions state changes; projects patient_tier_inclusions onto the patient's patient_service_plans.

on patient_subscription INSERT or status transition to 'active' | 'trialing':
    for each patient_tier_inclusions row of sub.tier_id:
        for q in 1..grant_quantity:
            INSERT patient_service_plans
                (cloned from inclusion.service_plan_id template,
                 source_tier_subscription_id = sub.id,
                 access window aligned to sub.current_period_*)

on patient_subscription status transition to 'canceled' | 'expired' | 'past_due':
    UPDATE patient_service_plans
        SET status = 'expired'
        WHERE source_tier_subscription_id = sub.id AND status = 'active'
    -- soft expire only (CLAUDE.md: never hard-delete patient records;
    -- consumed sessions remain visible as historical record)

on patient_subscription period rollover (current_period_starts_at advances):
    for each patient_tier_inclusions row of sub.tier_id where grant_period = 'per_subscription_period':
        if carry_over_unused = FALSE:
            expire previous period's patient_service_plans (status := 'expired')
        clone fresh patient_service_plans (as on subscription start)

on patient_subscription tier_id change (tier upgrade/downgrade mid-period):
    expire patient_service_plans where source_tier_subscription_id = sub.id
    re-run provisioning for the new tier:
        for inclusions where prorate_on_upgrade = FALSE: full grant
        for inclusions where prorate_on_upgrade = TRUE:  scaled by remaining period fraction

This hook runs atomically with the subscription state change and audit-logs via the standard P10 path. There is no parallel role-binding hook — patients have no role at the org, so subscription state changes never touch RBAC.

Layer 3 dependency: patient_service_plans.source_tier_subscription_id. When patient_service_plans is fleshed out at Layer 3, it must include a nullable FK column source_tier_subscription_id UUID NULL REFERENCES patient_subscriptions(id). NULL = patient purchased directly (the existing flow described in Service Catalog); set = auto-granted by a tier subscription. The booking-against-plan logic doesn't differentiate; the column exists for revocation and audit traceability. To be added to data-model.md Area 3 in the Pass 2 propagation.

Counter semantics for non-session entitlements. Today service_plans tracks sessions_total (consumed by appointment bookings). For an inclusion like "4 specialist-assigned treatment plans included," the counter measures treatment-plan assignments, not appointment sessions. This requires a small extension to service_plans at Layer 3 — either a parallel counter column (treatment_plan_assignments_total INT NULL) or a more general entitlement-type abstraction. Not a Layer 1 problem; flagged here so the data-model reconciliation pass picks it up. Until that extension lands, tier inclusions can be modeled for any entitlement that maps to existing service_plans.sessions_total semantics (consultations, therapy sessions, evaluations, etc.).

Example. A clinic on Pro + Pachet Telereabilitare defines an Annual Premium tier with two inclusions:

  • inclusion 1: service_plan = "4 consultations / 12 months" (session-based, sessions_total=4, validity_days=365), grant_period = per_subscription_period, grant_quantity = 1, carry_over_unused = FALSE.
  • inclusion 2: service_plan = "4 specialist treatment-plan assignments / 12 months" (uses the future Layer 3 counter extension), same config.

When Maria subscribes to Annual Premium:

  • 2 patient_service_plans rows auto-created, each with source_tier_subscription_id = Maria's tier sub.
  • Maria books a free consultation → existing booking flow finds the active patient_service_plans and decrements its sessions_completed.
  • 12 months later, Maria's tier renews (current_period_starts_at advances) → service hook expires the old period's rows, regrants fresh 4+4 for the new period.
  • Maria upgrades to a hypothetical higher tier mid-period → old inclusions expire, new tier's inclusions are full-granted (default prorate_on_upgrade = FALSE).

Money flow today vs. future

Today (Layer 1+, clinic-owned billing): payment_provider = 'external'. The clinic charges patients out-of-band. The clinic's admin or customer_support staff updates patient_subscriptions via Console/Clinic UI when payment is received. Or — for clinics with their own billing system — a webhook into POST /v1/orgs/{orgId}/patients/{patientId}/subscription flips state programmatically. We never see the money.

Future (platform-mediated, Stripe Connect): A new billing domain wires up Stripe Connect. patient_subscriptions.payment_provider flips to 'stripe_connect', external_subscription_id holds the Stripe sub ID, status changes are driven by Stripe webhooks. The schema reservations made now (payment_provider, external_subscription_id) ensure no migration of patient_subscriptions itself when this lands — the additive surface is the new billing tables (invoices, payment methods, payouts, tax records). That decision is its own design moment, not part of this doc.


Portal sign-up integration (post-1.26)

Portal onboarding goes through the portalonboarding domain — POST /v1/portal/onboard. The endpoint is mounted outside OrganizationContext (a fresh human with no patients row would otherwise 403 the gate) and provisions the full chain in one AdminPool transaction:

on portal sign-up for org O:
    INSERT INTO patient_profiles (human_id, ...)
    INSERT INTO patients (organization_id, patient_profile_id, ...)
    -- Resolve default tier directly; no organization_settings.default_signup_role_id indirection.
    SELECT id, version FROM patient_tiers
     WHERE organization_id = O AND is_default = TRUE AND is_active = TRUE
    -- tier_version snapshots patient_tiers.version at subscribe time (data-model.md Area 16).
    INSERT INTO patient_subscriptions
        (organization_id, patient_id, tier_id, tier_version, status='active', ...)
    -- Snapshot from the active version of the tier.
    INSERT INTO patient_subscription_entitlements SELECT ... FROM patient_tier_entitlements WHERE tier_id = ...
    INSERT INTO patient_subscription_limits       SELECT ... FROM patient_tier_limits       WHERE tier_id = ...

Idempotency: re-onboarding the same human at the same org returns the existing chain (200, no audit). The non-idempotent path emits three audit rows (one per row created).

No organization_memberships row is ever created for patients. Portal session resolution in OrganizationContext reads Subject.PatientOrgIDs (loaded alongside Memberships at sign-in) and grants org scope without a staff membership row, setting IsPatientSession = true. Staff role + permissions loading is skipped on the patient path; org-entitlements are still loaded for portal entitlement gating.


Usage metering (shipped — 1C.7)

This section previously specified usage_events and usage_counters

Neither table was ever built. Foundation 1C.7 shipped a three-table model with different columns and a different maintenance story, and this doc kept describing the design it replaced — long enough that anything planned against the old names was planned against a schema that does not exist. Corrected 2026-08-11. Authoritative detail lives in foundation.md → 1C.7 and migration 000019.

Three tables, split by the three different questions usage has to answer:

TableShapeQuestion it answersWritten by
usage_recordsEvent log, range-partitioned monthly on occurred_at (P41)What happened? Source of truth. One row per metered call, append-only, CHECK (units >= 0)metering.Record
usage_quotasState, one row per (org, capability, period)May this next call proceed? The live counter EnforceLimit and Reserve readReserve / Refund / EnsureQuotaRow / AdvanceExpiredQuotas
usage_summariesFrozen history, one row per (org, capability, period, period_start)What did last month total? Survives the quota reset; what an invoice is rebuilt fromcmd/usage-summary-rollup

Keyed on capability, not limit_code. The bridge between the two is the in-process metering.Registry (limit_code → (capability, period, unit_type)), populated at startup; an unregistered limit_code still drives the soft pre-check from organization_subscription_limits but has no live counter.

All three: RLS SELECT gated on usage.view_org; INSERT/UPDATE/DELETE revoked from restartix_app. Writes go through internal/core/metering on the admin pool, which is what makes append-only a property of the deployment rather than a policy. Read surface is GET /v1/organizations/{id}/usage (the Organization → Usage tab).

Reserve/Record, not a background reducer. The counter is incremented atomically before the provider call (UPDATE ... WHERE current_units + n <= limit_units, 0 rows ⇒ over cap) and the event row is written after success, with a refund on failure. Postgres re-evaluates the predicate against the locked row, so two concurrent reservations cannot both take the last slot. The earlier design's asynchronous reducer over an event stream could not offer that.

Period boundaries are calendar UTC. cmd/usage-quota-reset zeroes counters and rolls the window; cmd/usage-summary-rollup freezes the closed period; cmd/api-partition-roll provisions next month's usage_records partition. All three are scheduled in production.

Known gaps (2026-08-11)

  • Lifetime caps cannot be counted through usage_quotas. usage_quotas.period is CHECK (period IN ('day','week','month')), so period_kind = 'lifetime' codes get no row and LimitState.Current stays 0 forever — which is why EnforceLimit passes on every cap it is mounted on. The mechanism for this family is COUNT(*) at the endpoint; max_staff_seats is the worked example (invites.Service.enforceSeatCap). EnforceLimit itself remains a no-op and its two mounts (max_specialists, max_webhook_subscriptions) still enforce nothing.

  • A missing quota row is reported as "over cap." FIXED 2026-08-11. Reserve returned ErrQuotaExceeded for both "gate breached" and "no current-period row", and EnsureQuotaRow fires only from a subscription mutation — so an org provisioned by the org-creation trigger had subscription-limit rows and no quota rows at all, making "your plan refuses this" the default answer for every metered capability at every clinic. Reserve now materialises the row from the org's own organization_subscription_limits and retries; ErrQuotaExceeded means the cap was genuinely breached. It also rolls a row whose window has closed, which removes a second window where every metered call failed for the same "no usable row" reason — between the calendar boundary and usage-quota-reset's next run, i.e. the first of every month. The ErrNoQuotaRow sentinel is gone with the behaviour it named.

    Note this makes quota rows lazily provisioned: they appear on first metered use rather than at org creation. Eager creation would need the registry's limit_code → capability mapping inside SQL the trigger runs, plus a backfill; lazy materialisation is self-healing and needs neither. The visible consequence is that a clinic's allowance ("0 of 100 emails") is not on the usage surface until the first call — the read side should derive an unspent allowance from organization_subscription_limits rather than waiting for a counter row.

  • video_participant_minutes_per_month is seeded but unregisteredFIXED 2026-08-11, registered in cmd/api against video.MeterParticipantMinutes. The billable meter (video.billable_minutes) is deliberately left unregistered: it is the clinic-facing number derived from completed appointments, and a quota row would imply a cap the platform does not sell.

  • Limit merge disagrees with this doc. RESOLVED 2026-08-11 in favour of the code: caps SUM, overrides replace. See Limit resolution.

  • A clinic admin can move their own org onto any tier. subscriptions.manage is granted to the admin role TEMPLATE, so every clinic's own admin can call POST /v1/organizations/{id}/subscriptions and put themselves on dedicated — unlimited caps, every commercial entitlement — with no payment provider wired to take money for it. Harmless while tiers gated nothing; live since the tier gate started gating on 2026-08-11. Settled 2026-08-11: leave the grant in place. The permission is the right long-term model, there is exactly one clinic on the platform, and F12 is where a self-service upgrade path gets a payment provider to sit behind it — so the hole closes when the thing that makes it exploitable also makes it chargeable. Revisit when a second clinic onboards, whichever comes first. Console's Plan tab is the sales-led path and the Clinic app deliberately offers no upgrade button, but neither is a control: the endpoint is reachable directly with any clinic-admin token.

  • Patient-tier limits are storage only. patient_tier_limits and patient_subscription_limits are read by zero Go files. A clinic can define a patient tier and attach entitlements to it, but cannot cap anything on it — "Premium = 10 sessions/month" is expressible in the schema and enforced nowhere. The org side is live; the clinic→patient side is not. Deferred deliberately (2026-08-11), recorded here so it does not read as finished.


Over-cap state

When an org downgrades and falls below an existing usage level (e.g. tier downgrade lowers max_patients from 1000 to 100, but the org has 600 active patients):

  • hard_block behavior: existing rows are not retroactively deleted. The cap blocks future INSERTs. The org is "over-cap" until they upgrade or delete patients. Surface this state in admin UI as a warning.
  • soft_meter behavior: same — existing rows persist, future events still meter.

The platform never deletes user data because of a billing change. CLAUDE.md ("soft delete only — never hard-delete patient records") forbids it anyway. This is a product/UX problem, not a schema problem.


Layer 1 reservation

What this design lands at Layer 1 (the foundation), and what is deferred to later layers because the schema FKs to entities introduced there. Cross-reference: implementation-plan.md sequences the work; this section catalogs the dependencies.

Lands at Layer 1 (foundation) — owned by 1.19, 1.20, 1.21:

  • Tables: tiers, tier_versions, entitlements, limit_definitions, tier_entitlements, tier_limits, organization_subscriptions, organization_subscription_entitlements, organization_subscription_limits, organization_subscription_overrides, patient_tiers.
  • Org-side companion tables (1.19): organization_settings, organization_billing, organization_entitlements. See org-settings.md.
  • Catalog seed migration: an initial set of tiers (free, pro, dedicated), an initial set of entitlements (the regulated four matching organization_entitlements columns plus a few non-regulated ones), an initial set of limit definitions (max_patients, max_specialists, max_storage_bytes).
  • Forward-compat columns: payment_provider and external_subscription_id on organization_subscriptions.
  • Permissions seeded: subscriptions.view_org, subscriptions.manage, patient_tiers.manage (plus the organizations.update_settings, organizations.manage_billing permissions covered in org-settings.md). Granted to admin template.
  • RLS policies on every Layer 1 table per the rules above.
  • current_app_has_org_entitlement(entitlement_code TEXT) SQL function (also covered in org-settings.md).
  • Initial organization_subscriptions row created when an org is provisioned: a single free base subscription, status active, with snapshots of entitlements and limits. This makes the resolver always return a defined answer (no "no subscription" edge case).
  • Initial default patient_tiers row created when an org is provisioned. Post-1.26 the tier carries no role_id — patients are not memberships, and the org-creation trigger no longer touches organization_settings.default_signup_role_id (column dropped in 1.26).

Lands at Layer 2 — alongside patients (owned by Layer 2.5):

patient_subscriptions.patient_id FKs to patients(id) introduced in Layer 2.2, so the table cannot physically exist before Layer 2.

  • Tables: patient_subscriptions + snapshot tables patient_subscription_entitlements, patient_subscription_limits, patient_subscription_overrides (mirrors of the org-side billing engine, with payment_provider and external_subscription_id forward-compat columns on the parent).
  • Permissions seeded: patient_subscriptions.view_org, patient_subscriptions.manage. Granted to admin and customer_support templates.
  • RLS policies per the rules above.
  • Portal sign-up integration: POST /v1/portal/onboard provisions patient_profiles + patients + patient_subscriptions (with snapshots) in one AdminPool transaction. No tier→role binding — patients have no role at the org.

Lands at Layer 3 — alongside service_plans (owned by Layer 3.2):

patient_tier_inclusions.service_plan_id FKs to service_plans(id) introduced in Layer 3.2, so the table cannot physically exist before Layer 3.

  • Table: patient_tier_inclusions.
  • RLS gated by patient_tiers.manage (already seeded at Layer 1).
  • Tier-inclusion provisioning service — the provision / revoke / rollover hook described in Tier → entitlement provisioning.
  • patient_service_plans.source_tier_subscription_id FK column — to be added to data-model.md Area 3 in the Pass 2 propagation.
  • service_plans counter extension for non-session entitlements (e.g. treatment_plan_assignments_total) — designed when service_plans is fleshed out for first-class implementation.

Deferred (independent of People / Service Catalog timing):

  • RequireTierEntitlement, EnforceLimit, RequireOrgEntitlement middleware — designed in middleware-composition.md; shipped when the first entitlement/limit-gated endpoint lands. Layer 1 ships them as callable infrastructure with stub resolution (1.22).
  • usage_events and usage_counters tablessuperseded. Shipped instead as usage_records / usage_quotas / usage_summaries in 1C.7 (migration 000019). See Usage metering.
  • Entitlement projection service (SubscriptionService.RecomputeOrgEntitlements) — can ship at Layer 1 as a no-op (no regulated entitlements in any tier yet) and be activated when the first regulated entitlement appears in a tier. Until then, organization_entitlements is superadmin-managed only.
  • Console UI for managing subscriptions, overrides, entitlements, and patient-tier catalog — Layer 1 (1.13). Console UI for patient_subscriptions lifecycle — Layer 2.5+.
  • External billing wire-up (Stripe / Chargebee for the platform side) — its own decision moment, not Layer 1.
  • Stripe Connect for patient subscriptions — its own decision moment.

Stub semantics by phase.

  • Layer 1. Schema structurally complete on the org side: every org has a subscription, every subscription has snapshots, every org has a default tier. No request is gated on entitlements/limits yet because no RequireTierEntitlement calls exist. The schema is the contract; the gating is incremental.
  • Layer 2.5. patient_subscriptions becomes live and is provisioned by POST /v1/portal/onboard alongside the patient row. No tier→role binding (patients have no role). Patient-tier inclusions still don't fire (table not yet created).
  • Layer 3.2. patient_tier_inclusions lands; the provisioning hook becomes live, projecting tier subscriptions onto patient_service_plans. Counter extensions on service_plans allow non-session entitlements (treatment-plan assignments) to be metered.

Open questions

QuestionDecided in
Manual entitlement lock — should organization_entitlements carry a manually_locked_<entitlement> flag so superadmin disables aren't overwritten by the tier engine projection?Before the tier engine projection ships
Tier migration on version bump — when a customer is on pro v3 and we publish pro v4, do we offer one-click upgrade, force-upgrade at renewal, or hold them on v3 indefinitely?Before the first tier version bump in production
Trial mechanics — is trialing per-subscription, per-org-lifetime ("one trial per org"), or per-tier ("one trial per tier code")? Affects schema (where trial_used_at lives).Before self-service signup ships
Add-on cancellation atomicity — if a base tier is canceled, do active add-ons cancel too, or persist as orphans?Before sales motion uses add-ons
Currency consolidation — can an org hold subscriptions in mixed currencies (RON base + EUR add-on)? Probably no, but call it out.Before second-currency support lands
organization_subscription_overrides audit — should overrides have their own audit_log.action_context = 'override' or fold into the standard mutation log?First override grant in production