canAccess() call. Visual flows, a phased delivery plan, and section-by-section documentation matching the source design doc.§6 — 7 platform roles across the Platform → Country → Center → Site scope chain. A higher scope includes every lower scope within its boundary; configuration roles (amber) never touch business/medical data, operational roles (blue) never touch global configuration.
§2 — Break-Glass is evaluated first (it must not sit behind the gates it exists to bypass), then Phase 1 Entitlements (commercial, cacheable, skipped for RBAC-only permissions), then Phase 2 Authorization (RBAC/ReBAC/ABAC). Every refusal carries a gate + reason.
§3.1, §3.3, §3.3.3 — resources × actions = permissions is a small, Platform-owned catalog owned by neither domain. capability_permissions (0..1) is what routes a request into commercial entitlement checking or straight past it for RBAC-only/administrative permissions.
§7.1, §3.7.1 — subscription states (trial/active/suspended/reactivated/cancelled/expired) plus the independent Draft → Review → Publish plan-version workflow that makes the frozen-snapshot guarantee enforceable.
§11 — RBAC Core and Entitlements are structurally independent of each other; both depend only on the shared Access Catalog. Orchestration/Audit sits above both, the one legitimate place that needs visibility into every cluster at once.
Lock the eight open decisions the document explicitly defers to governance/product/security (§12.3), since two of them (deployment topology, consistency SLA) shape every phase after this one.
Stand up the neutral resource layer first — resources, actions, permissions — since both domains reference it and it has zero dependency on either (§3.1, §11).
Implement roles, scoped assignment, deny overrides, operational delegation and the break-glass escape hatch — independently testable from Entitlements.
Model what LOKTORE actually sells (capabilities) separately from how it's grouped for marketing (modules) and separately again from how it's clicked (menus).
Build Draft → Review → Publish plan versioning so editing a plan never retroactively changes what an active subscriber can do — the document's stated core problem.
Implement the polymorphic subscription + frozen snapshot, then the N-independent-entitlements algorithm that lets a capability require any number of subscriber types.
Wire Break-Glass → Phase 1 → Phase 2 behind one function, then layer the tri-state navigation model on top for menu rendering.
Expose the orchestrator and both services over HTTP, plus the internal integration point other services should call instead of reimplementing checks.
Close the gap between a functionally-correct design and one that survives 5,000 req/s — the document's own scalability review (§13).
The document's own recommended near-term path: build the Insurance-facing portal against the new model rather than attempting a full historical remap first.
LOKTORE controls two independent questions behind a single call, canAccess(user, action, resource, context): does the relevant party's subscription cover this feature (Entitlements), and is this specific user allowed to perform this action (Authorization/RBAC). The rest of the codebase never needs to know two systems exist.
In the original design, editing a commercial plan changed access immediately for every center already subscribed to it — contradicting LOKTORE's own commitment that pricing and features are locked until renewal. This revision introduces a frozen snapshot per subscription and full version history for plans (composition, price, limits), so a plan can be edited and published safely without touching any existing subscriber.
Built from functional_capabilities — the atomic, developer-owned features LOKTORE actually implements (teleconsultation, e-prescribing, claims tracking). Commercial modules (e.g. “Clinical Tools”) are a freely reconfigurable, Super-Admin-managed grouping with no technical logic of their own; plans reference capabilities directly, so reorganizing modules never affects a plan already sold. A Draft → Review → Publish workflow, backed by granular permissions, prevents accidental live edits.
Administrative rights (Super Admin → Country Admin → Country Staff) follow standard RBAC, confined by one explicit rule: nobody can grant a permission or scope they do not themselves hold. Checked at grant time, not on every subsequent use (§9.2.2).
The engine as specified would not scale past a few hundred requests/second without caching. Remediation: data classified by staleness tolerance, materialized permission/entitlement lookups, asynchronous audit logging, and an explicit bounded-staleness consistency commitment requiring security sign-off (§13).
Capabilities can require independent subscription from more than one kind of subscriber (a Center, a Patient, both, or more) — each side checked only against its own frozen subscription, never arbitrated against the other's plan version. Every subscriber type is a configurable catalog row (subscriber_types), never hard-coded (§1.3). Funding capability is derived from subscriber_type_funding_rules, never a flag.
Ready for the SQL constraint matrix. Eight items remain for governance/product/security sign-off rather than engineering — see §12.3.
v1.0 merged all 10 checks (subscription status, plan coverage, module activation, roles, overrides, delegation, break-glass) into a single can() function. This worked operationally but created two problems: no frozen snapshot (editing a plan retroactively changed access for active subscribers, contradicting LOKTORE's 30-days'-notice commitment), and coupled ownership (commercial + RBAC logic in one engine). v2.0+ keeps the single-entry-point ergonomics while splitting the internals into two independently ownable services and adding the snapshot mechanism.
Every kind of subscriber — health center, patient, organization, insurer, or any future kind — is a row in subscriber_types, not a hard-coded value. The engine has no built-in concept of “CENTER” or “PATIENT.” Funding capability is derived, never a flag: whether a type can fund others is determined entirely by the existence of a row in subscriber_type_funding_rules. Creating/modifying a subscriber_types row is a structural decision reserved for Developer/System Administrator, never the functional Super Admin.
A capability can require entitlement from any number of independent subscriber types. None are arbitrated against a shared policy version — each party resolves independently against its own eligible sources, and ALL required types must independently pass:
transaction_allowed = AND over every required subscriber_type: resolvePartyEntitlement(partyType, partyId, capabilityId, permissionId, activationContext)An imbalance between the number of parties of each required type is a normal commercial supply/demand signal, never a system inconsistency (§3.3.2).
canAccess() evaluates a break-glass check first, then two phases in order — see the Flowcharts tab for the visual version of this section.
Moved from Phase 2 step 9 to an early bypass branch — placed at the end, it was unreachable for its primary purpose (a suspended subscription would REFUSE at Phase 1 step 1 long before reaching it).
| Step | Check | If matched |
|---|---|---|
| 0 | Active break_glass_sessions row whose scope covers this action/resource/context? | AUTHORIZED immediately, bypassing both phases, mandatory audit entry + any recorded constraints. No match → continue to Phase 1. |
Algorithm rewritten around entitlement SOURCES (own subscription OR funding link), not the assumption that every party holds its own subscription — this correctly handles a Patient covered only through an NGO.
| Step | Check | If Failed |
|---|---|---|
| 1 | For each required party: candidate sources = own active subscription(s) + active subscription_party_links entries | REFUSED — reason: subscription_inactive, if zero candidate sources exist |
| 2 | Discard sources whose frozen subscription_capability_access excludes the capability | REFUSED — reason: not_in_plan, if none survive |
| 3 | Among remaining sources, discard any whose frozen subscription_action_limits excludes the action | REFUSED — reason: action_excluded_by_plan, if none survive |
| 4 | Is the capability operationally enabled for this country/center_type/center? (capability_activations) | REFUSED — reason: capability_disabled |
| 5 | Party PASSES if ≥1 source survived steps 1–3 and step 4 passed | AUTHORIZED for this party — next party, or Phase 2 if last |
A permission with NO row in capability_permissions (e.g. PLAN+CREATE, an RBAC-only/administrative permission) skips Phase 1 entirely — a deliberate branch based on the presence of a capability mapping, not on whether context carries a centerId.
Fixes a v1.0 defect: role-permission failure used to terminate the pipeline before delegation (evaluated after it) was ever reached — a pure delegate was always refused. Fix: grant source is now the disjunction of role-permission and delegation.
| Step | Check | If Failed / If matched |
|---|---|---|
| 5 | Grant source: role grants this permission at matching scope, OR a valid/accepted/non-expired delegation? | REFUSED — reason: no_grant_source |
| 6 | Deny override for this user + permission? (checked regardless of role vs. delegation grant) | REFUSED — reason: permission_denied — always wins over both |
| 7 | Is a configuration role trying to access business/medical data? (role_category vs. resources.data_category) | REFUSED — reason: config_role_cannot_access_business_data |
| 8 | All gates passed | AUTHORIZED |
canAccess(user, action, resource, context)Response: { status: AUTHORIZED|REFUSED, gate: break_glass|entitlement|authorization|null, reason, missingSubscriberType?, rulesEvaluated: [...] }. The constraints field (trialEndsAt, maxUsers, …) is removed — canAccess() never evaluates capacity; that's a separate checkCapacity() call by the business service.
This is the section carried into LOKTORE Utility's live 118-table model (governance + entitlements domains, v3.7.1). The subsections below map 1:1 onto that implementation — cross-reference against the Data tables / ER diagram tabs for live column lists.
A stable, UI-independent business object layer sitting between commercial capabilities and RBAC permissions, owned by neither domain (renamed acl_resources in this app to avoid colliding with the pre-existing scheduling.resources physical-resource table). Introduced so the same business object (a claim) can be secured and entitled once, then exposed identically through multiple portals' navigation.
roles (scope_level, role_category), role_permissions, user_roles (scoped country/center/site), user_permission_overrides (deny-only). A configuration-category role cannot be assigned a permission on a business/medical-category resource — rejected at write time, not only at request time.
functional_modules / functional_capabilities / module_capabilities are the commercial catalog. capability_permissions (capability ↔ permission, i.e. capability ↔ resource+action) replaces the earlier capability_resources — this is what disambiguates two capabilities sharing a resource, e.g. CLAIM_SUBMISSION (CENTER+INSURANCE) vs. CLAIM_REVIEW (INSURANCE alone). UNIQUE(permission_id): a permission can map to at most one capability.
capability_subscriber_requirements declares which subscriber_types must independently pass entitlement for a given capability — generic, no special-cased type anywhere in the table or resolution engine.
A capability can be funded by one subscriber type and consumed by another (e.g. an Insurer funding Patient teleconsultation without appearing in the Patient's own navigation) — an imbalance between funders and consumers is a commercial signal, not a data-integrity problem.
A permission with no capability_permissions row is RBAC-only / administrative (creating a plan, managing the Access Catalog, assigning a role) — no subscription entitles it, so Phase 1 is skipped entirely for it, always.
Operational delegation (view / manage_availabilities / create_appointment / modify_appointment / cancel_appointment / configure_booking) — created (pending) → accepted → effective. Revocable any time, optional expiry, auto-revoked on detachment from center/site.
subscriber_types — every kind of subscription holder, wired to a registered identity resolver. Two-level governance: configuration is not no-code — adding a row makes the engine aware such a party may hold subscriptions, but the portal/roles/scopes/menus remain a separate engineering deliverable.
subscription_plans (polymorphic holder_type_id), plan_eligible_center_types. LOKTORE plans combine two independent axes: Depth (billing-only vs. full practice tools) and Visibility (invisible vs. patient-discoverable).
A plan can be restricted to specific center types; once a plan has ever had a subscription, its audience is locked in — a commercial mechanism kept distinct from the operational capability_activations gate.
plan_versions (status: draft/live/archived) with plan_version_capabilities / plan_version_action_limits / plan_version_capacity_limits, immutable once published (DB-enforced trigger). Granular permissions (§9.2) make prepare-vs-publish a real permission boundary, not a UI convention.
3.8.1 Polymorphic Owner — subscriptions.owner_type_id/owner_id validated at write time via the identity_resolvers registry. 3.8.2 Funding Relations — subscriber_type_funding_rules is the sole source of truth for who can fund whom. 3.8.3 Coverage Links & Quotas — subscription_party_links + subscription_party_limits (isWithinLimit() NULL-safe helper). 3.8.4 Funding Rules. 3.8.5 resolvePartyEntitlement() / evaluatePartyEntitlement() — source-based, permission-precise resolution. 3.8.6 Action-Limit Freeze — subscription_action_limits. 3.8.7 Internal Capacity Limits — capacity_dimensions ↔ subscriber_types via capacity_dimension_subscriber_types (many-to-many, v4.4), replacing max_users/max_sites; PROFESSIONALS and STAFF are counted by their own resolver, never by counting RBAC role assignments. 3.8.8 Trial Abuse Prevention — subscription_trial_usage, UNIQUE(owner_type_id, owner_id, plan_id) as the actual arbiter, not an application check-then-insert.
subscription_history (full version-aware change log) and subscription_frozen_data (data tied to removed modules on downgrade — preserved, restored automatically on re-upgrade, never deleted).
authorization_rules (versioned), authorization_decisions_log + authorization_decision_entitlement_sources (one row per source actually evaluated, plus grant_sources JSONB recording every ROLE/DELEGATION that granted). Freezes permission_id and capability_id at decision time — audit records are never reconstructed from the live Access Catalog. No synchronous write on the request path (§13.5).
break_glass_sessions — token_hash, granted_by, scope JSONB, is_active, post_review_completed. Reclassified into a fourth Orchestration/Audit group alongside the decision log, since it legitimately needs visibility across RBAC and Entitlements for audit purposes.
Owns subscriber_types, subscription_plans, plan_versions and its children, functional_capabilities, capability_permissions, capability_subscriber_requirements, subscriptions and everything under it, capacity_dimensions and its children. Does NOT own resources — shared, platform-owned, read-only from here. No dependency on the RBAC engine.
| Function | Description |
|---|---|
| hasEntitlement(subscriptionId, resourceCode) | Coarse aggregate check against the frozen snapshot — capability inclusion AND action-limit inclusion both required |
| getEffectiveEntitlements(subscriptionId) | Full resolved capability → permission tree for a subscription, no submenu knowledge |
| evaluatePartyEntitlement(partyType, partyId, capabilityId, permissionId, activationContext) | Single source of truth for the decision AND its reason — { status, reason } |
| resolveActivationContext(parties[], countryId) | Shared helper producing { countryId, centerTypeId?, centerId? } — one resolution rule for both canAccess() and buildMenuTree() |
| resolvePartyEntitlement(...) | Thin boolean wrapper around evaluatePartyEntitlement().status = PASS |
| createPlanVersion / setCapacityLimit / getCapacityLimit / checkCapacity | Plan authoring and the capacity enforcement primitive (§3.8.7) — checkCapacity() must run inside the same atomic operation as the write it gates |
| extendTrial(subscriptionId, additionalDays, extendedBy, justification) | Moves trial_ends_at forward; refused unless an active trial; immutable after creation |
| createSubscription(ownerTypeCode, ownerId, planId, versionId?) | Generic replacement for per-type subscribe*() functions; resolves the subscriber_types row and its resolver; atomic first-trial check |
| linkParty / setPartyLimit / renewSubscription / downgradeSubscription | Funding links, quotas, and lifecycle transitions — all owner-agnostic |
| Function | Description |
|---|---|
| can(userId, action, resource, context) | Phase 2 gates only: grant source (role OR delegation), deny override, config/business separation. Break-glass is not part of this function. |
| mayGrant(actorId, targetPermissionSet, targetScope) | Confinement check (§9.2.1) — a distinct containment comparison, not a reuse of can() |
| getEffectivePermissions(userId, context) | (role grants ∪ active delegations) minus deny overrides — deny subtracted at the source, every caller gets a deny-safe result |
| getUserRoles / getUserScopes | All role assignments / all scopes where the user has roles |
Implements §2 end to end: (0) break-glass; (1) resolve (resource, action) → permission, check for a capability_permissions row; (2) if mapped, resolve activationContext once and evaluate every required subscriber type via evaluatePartyEntitlement(); (3) call authorizationEngine.can(); (4) merge into the §2.3 response shape. A missing capability mapping is an expected branch straight to Phase 2 — never an error.
buildMenuTree(userId, navigationParty?, context) — tri-state navigation (ENABLED / LOCKED / HIDDEN, §4.3/v3.2.5+). LOCKED never grants access; a direct API call behind a locked screen is refused exactly like any unauthorized attempt. submenus.navigation_permission_id (DB-trigger-enforced to belong to the submenu's own resource) removes the old “any permission on the resource” ambiguity.
Express authorize('delete','USER') factory constructs context per §2.3's shape — parties[] populated from whatever the request provides, never a bare top-level centerId — then calls accessOrchestrator.canAccess(); returns 403 with the structured { gate, reason } body if denied.
| Method | Path | Description |
|---|---|---|
| POST | /plans | Create plan (Super Admin, or delegated role) |
| POST | /plans/:planId/versions | Create a new draft version (Edit) |
| GET | /plans/:planId/versions | List all historical versions |
| GET | /plans/:planId/versions/:versionId/diff | Review Changes — diff a draft against the current live version |
| PUT | /plans/:planId/versions/:versionId/publish | Publish — draft becomes live, previous live becomes archived |
| POST | /modules | Create/rename a commercial module (presentational) |
| PUT | /modules/:moduleId/capabilities | Set module_capabilities composition |
| GET | /plans/compare | Compare two plan versions |
| POST | /subscriptions | Create a subscription — { ownerTypeCode, ownerId, planId, versionId? } |
| GET | /subscriptions/:subscriptionId | Get a subscription + resolved entitlements |
| GET | /parties/:partyTypeCode/:partyId/subscription | Resolve a party's own active subscription by type + id |
| PUT | /subscriptions/:subscriptionId/upgrade | Immediate re-freeze |
| PUT | /subscriptions/:subscriptionId/downgrade | Schedules pending_plan_version_id/pending_effective_at |
| PUT | /subscriptions/:subscriptionId/renew | Resync snapshot per plan policy |
| GET | /subscriptions/:subscriptionId/history | Full version-aware change history |
| GET | /check | hasEntitlement check (?subscriptionId, ?resourceCode) |
| GET / POST | /capabilities/:capabilityId/activations | List / set operational activation |
Menu/submenu management, permission & action management, role management, user RBAC, delegations, break-glass. Module/capability activation endpoints have moved out of this namespace entirely — activation now targets capability_id under /entitlements/capabilities/:capabilityId/activations, never module_id.
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/access/check | canAccess() for other internal services — the recommended internal integration point, replacing /internal/can |
Inside the recommended modular monolith this is an in-process call. The moment any external service consumes it over the network: mTLS/service-token auth, client-side caching with event-driven invalidation, a circuit breaker, and a documented fail-closed policy (treat unreachable/erroring as REFUSED, never AUTHORIZED) are mandatory from day one.
Unchanged from v1.0 — see the Role & Scope Hierarchy diagram in the Flowcharts tab for the visual version.
| Role | Scope | Category | Description |
|---|---|---|---|
| SUPER_ADMIN | platform | configuration | Global governance, all countries. Composes/edits any plan directly, no delegation needed. MFA mandatory. |
| COUNTRY_ADMIN | country | configuration | Manages entities within their country only. Can be granted plan-edit rights and re-delegate within their country. MFA mandatory. |
| CENTER_ADMIN | center | operational | Full management of a center and its organization. Can delegate operational actions to staff. |
| SITE_ADMIN | site | operational | Optional role. Manages local site operations. |
| HEALTHCARE_PROFESSIONAL | center | operational | Attached to a country, can work across multiple centers/sites. |
| STAFF | site | operational | Configurable delegated rights, scope defined by center/site/permissions. Country-scoped staff can hold plan-edit rights. |
| PATIENT | platform | operational | Searches centers/professionals, books appointments, views own data. No MFA. |
Scope hierarchy: Platform → Country → Center → Site. A higher-scope role includes access to all lower scopes within its boundary (a Burkina Faso Country Admin reaches every center/site in Burkina Faso, nothing in Côte d'Ivoire or Guinea). Configuration roles configure modules/rules/features/plans but cannot access patient/medical/business data; operational roles execute business operations but cannot modify global configuration — enforced via role_category.
LOKTORE plans are not a linear tier ladder — two independent axes: Depth (“Billing only” vs. “Practice on Loktoré” — clinical tools, on-call, insurance) and Visibility (invisible to the public vs. discoverable by patients). Real modules: Organisation & digital front desk, Clinical tools, On-call & continuity of care, Insurance & reimbursement, Billing & financial tracking, Visibility & patient acquisition.
States: active → suspended → reactivated → active · active → cancelled · active → expired · trial → active (after payment) · trial → expired.
| Feature | Billing only | All-in-one (no visibility) | Patient base mgmt | All-in-one |
|---|---|---|---|---|
| Organisation & front desk | YES | YES | YES | YES |
| Clinical tools | NO | YES | NO | YES |
| On-call & continuity | NO | YES | NO | YES |
| Insurance & reimbursement | NO | YES | NO | YES |
| Billing & financial tracking | YES | YES | NO | YES |
| Visibility & acquisition | NO | NO | YES | YES |
A user may hold multiple cumulative RBAC roles simultaneously. RBAC role assignment does NOT determine STAFF/PROFESSIONAL business classification (§3.8.7). The physical Identity/account model for STAFF vs. PROFESSIONAL — one authentication account with separate business profiles, or genuinely separate accounts — is owned by the Identity domain, not decided by this document (v4.6 walked back an earlier “one account” phrasing that overreached).
Example: a practitioner who is CENTER_ADMIN at Center A and HEALTHCARE_PROFESSIONAL at Center B holds full admin capabilities at A and provider-only capabilities at B.
A healthcare professional may hold CENTER_ADMIN and/or SITE_ADMIN in the SAME Center they practise in — a clinic director who is both HEALTHCARE_PROFESSIONAL and CENTER_ADMIN for the same Center. These combinations never affect commercial capacity classification: the user remains counted under PROFESSIONALS, never additionally under STAFF, regardless of how many admin roles they also hold. WHAT a person IS commercially and WHAT a person CAN DO administratively are two independent axes — neither derives the other.
There is no user-selected “acting role.” authorization_decisions_log records every grant source that actually granted the permission in grant_sources (every matching ROLE and any qualifying DELEGATION) — a prior “acting as” single-role phrasing was corrected as false whenever several roles independently grant the same permission at the same scope.
LOKTORE has two distinct delegation mechanisms. They are not interchangeable.
A professional remains master of their schedule. Any third-party action requires explicit, granular, modifiable, revocable delegation (delegations table). Delegable actions: view, manage_availabilities, create_appointment, modify_appointment, cancel_appointment, configure_booking. Lifecycle: created (pending) → accepted → effective. Revocable any time, optional expiry, auto-revoked on detachment.
The right to act on a plan is not a fixed role and not a single permission — it is delegated, country-scoped, and follows a chain: Super Admin (always authorized, all countries) → LOKTORE Staff (granted, platform-scoped) → Country Admin (granted, scoped to their country_id) → Country Staff (granted by their Country Admin, same country_id).
| Permission | Typical holder |
|---|---|
| VIEW_PLAN | Country Staff and above |
| CREATE_PLAN | Super Admin (any country_id incl. platform-wide), delegated Country Admin (own country only) |
| CREATE_PLAN_VERSION | Country Staff and above — opens a draft |
| EDIT_PLAN_VERSION | Country Staff and above — edits a draft |
| PUBLISH_PLAN_VERSION | Typically Country Admin and above only |
| ARCHIVE_PLAN_VERSION | Typically Country Admin and above only |
| ASSIGN_PLAN | Assign an existing plan to a center |
| VIEW_PLAN_HISTORY | Country Staff and above |
| MANAGE_MODULE / MANAGE_MODULE_COMPOSITION | Create/rename/delete a module / edit its composition |
| MANAGE_CAPABILITY_ACTIVATION | Edit capability_activations |
| MANAGE_CAPABILITY_PERMISSIONS | Edit capability_permissions — not granted by default |
| MANAGE_ACCESS_CATALOG | Create/edit resources, actions, permissions — Developer/System Admin only, never Super Admin |
| MANAGE_CAPACITY_DIMENSIONS | Create/edit capacity_dimensions — Developer/System Admin only |
| MANAGE_CAPABILITY_ENTITLEMENT_REQUIREMENTS | Edit capability_subscriber_requirements — not granted by default |
| MANAGE_SUBSCRIBER_TYPES | Create/edit subscriber_types — Developer/System Admin only |
| MANAGE_FUNDING_RULES | Create/edit subscriber_type_funding_rules — Super Admin |
This granularity makes Draft → Review → Publish a real permission boundary: a Country Staff can hold CREATE_PLAN_VERSION + EDIT_PLAN_VERSION without PUBLISH_PLAN_VERSION — they prepare, the Country Admin publishes. Each is an ordinary permission through user_roles/role_permissions — no separate grant table.
A user can never grant a permission or scope greater than what they themselves possess. Enforced at grant time via a dedicated mayGrant(actorId, targetPermissionSet, targetScope) function — not by re-checking the grantor on every subsequent use. Permission confinement: can only grant permissions currently held. Scope confinement: can only grant a scope contained within their own (a Burkina Faso Country Admin can never create a Côte d'Ivoire-scoped or platform-wide role).
Confinement is checked once, at grant time, deliberately NOT re-checked on every use. Plan-edit rights are ordinary RBAC assignments, not a dependent delegation chain — a Country Staff's granted permission becomes their own and does not depend on the Country Admin retaining it. Re-validating on every call would mean a manager leaving LOKTORE silently cascades to revoke dozens of legitimately onboarded staff — not a wanted property. What happens when an administrative permission is revoked is an open decision (§12.3).
| Category | Events |
|---|---|
| Menu/Submenu | MENU_CREATED, MENU_UPDATED, SUBMENU_CREATED, SUBMENU_UPDATED |
| Roles | ROLE_CREATED, ROLE_UPDATED, ROLE_DELETED |
| Role-Permission | ROLE_PERMISSION_ASSIGNED, ROLE_PERMISSION_REMOVED |
| User-Role | USER_ROLE_ASSIGNED, USER_ROLE_REMOVED, USER_ROLE_CASCADE_REVOKED |
| Overrides | USER_PERMISSION_OVERRIDE_ADDED, USER_PERMISSION_OVERRIDE_REMOVED |
| Delegations | DELEGATION_CREATED, DELEGATION_ACCEPTED, DELEGATION_REVOKED, DELEGATION_EXPIRED |
| Modules & Capabilities | CAPABILITY_ACTIVATION_CHANGED, MODULE_COMPOSITION_CHANGED, CAPABILITY_ENTITLEMENT_REQUIREMENT_CHANGED, CAPABILITY_PERMISSION_CHANGED |
| Resources | RESOURCE_CREATED, RESOURCE_MODIFIED (Developer/System Admin only) |
| Subscriber Types & Funding | SUBSCRIBER_TYPE_CREATED, SUBSCRIBER_TYPE_MODIFIED, FUNDING_RULE_CHANGED |
| Authorization | AUTHORIZATION_DENIED (security event, tagged with gate) |
| Break-Glass | BREAK_GLASS_ACTIVATED, BREAK_GLASS_REVOKED, BREAK_GLASS_ACTION_PERFORMED |
| Plans | PLAN_CREATED, PLAN_VERSION_DRAFT_CREATED, PLAN_VERSION_PUBLISHED, PLAN_VERSION_ARCHIVED, PLAN_ELIGIBLE_CENTER_TYPES_CHANGED |
| Subscriptions | SUBSCRIPTION_CREATED, SNAPSHOT_FROZEN, UPGRADED, DOWNGRADED, RENEWED, SUSPENDED, CANCELLED, REACTIVATED, FROZEN_DATA_RESTORED, PARTY_LINK_CREATED/REMOVED/EXPIRED, PARTY_LIMIT_CHANGED, CAPACITY_LIMIT_CHANGED, TRIAL_EXTENDED |
| Role | Audit Log Access |
|---|---|
| SUPER_ADMIN | Global access to all logs |
| COUNTRY_ADMIN | Only logs relating to their country (incl. plan_versions they/delegates created) |
| CENTER_ADMIN | Only logs relating to their center and attached sites |
| SITE_ADMIN | Only logs relating to their site |
| Operational roles | Own personal actions within functional scope |
Restructured around four groups — see the Domain Cluster diagram in the Flowcharts tab. RBAC and Entitlements are structurally independent of EACH OTHER; neither references a table owned by the other. Both depend on a shared, Platform-owned Access Catalog (resources, actions, permissions) — the only tables either references outside its own boundary for its own operation.
This is a stronger form of the “single intentional bridge” principle: rather than one domain reaching into the other's schema, both domains reach into neutral, stable ones — the Access Catalog for day-to-day operation, Orchestration/Audit for after-the-fact record-keeping.
The single canAccess() entry point, the two-engine split, refusal-by-default, zero-trust, and the role/scope hierarchy (§6) are unchanged since early revisions — this is a maturing, not a rewriting, of the original design.
Answers one question: can the design in §1-§12 run strongly consistent and scale to thousands of requests/second? Short answer — it is strongly consistent today only because every check reads PostgreSQL directly, which is exactly why it will not scale as specified without the changes below. Scaling requires introducing controlled, bounded staleness, made explicit rather than implicit.
| Class | Data | Budget |
|---|---|---|
| A — slow-moving policy | menus, submenus, actions, permissions, role_permissions, plan_versions & mappings, module_capabilities, capability_permissions, resources | 5–15 min cache, event-driven invalidation |
| B — per-user assignments | user_roles, delegations, materialized effective-permission sets | 1–5 min cache, immediate invalidation on assignment events |
| C — security-critical negatives | deny overrides, break-glass revocation, subscription suspension, capability_activations off | Live, or seconds with synchronous write-through invalidation |
Precompute effective permission sets per (user, scope) in Redis (rbac:eff:{userId}:{scopeHash}); entitlement gates resolved once per subscription_id and shared (entitlements:eff:{subscriptionId}). Deny overrides checked against a separate, always-fresh structure so a cached grant can never survive an explicit deny. Multi-funder resolution materialized as party_entitlement:{partyTypeCode}:{partyId}:{capabilityId} → { entitled, allowedPermissionIds }.
Refines, doesn't replace, the §12.3 one-service recommendation: the Governance service is the Policy Administration Point (source of truth, only writer); canAccess() reads a locally replicated policy snapshot (in-process cache backed by Redis) as the Policy Decision Point — same service boundary, not a network hop. Fail-closed: an unverifiable policy snapshot (cache empty AND PAP store unreachable) → reject, never authorize.
Decisions published to a queue, batch-inserted — never synchronous on the request path. 100% of REFUSED + 100% of break-glass logged; AUTHORIZED sampled 1–10% (raisable during an investigation). Partitioned by month; UPDATE/DELETE revoked from the application's DB role.
Target: bounded staleness with event-driven invalidation, not global strong consistency. Class C ≤5s, Class A/B ≤60s. Must be reviewed and signed off by security/compliance — it changes what “Zero-Trust — every access is verified” means in practice (§1.1).
Load: p99 ≤5ms on cache hit, ≤50ms cold path, 5,000 sustained checks/sec with PostgreSQL CPU <50%. Chaos: Redis loss (fail-closed + recovery), PAP outage (PDP survives on last-known-good snapshot within budget), invalidation storm without cache stampede (request coalescing/singleflight). Correctness: pure-delegate authorized, break-glass on suspended subscription, revocation-to-effect latency measured per class against the §13.7 SLA.
A full v1.0→current schema migration (frozen plan_versions, functional_capabilities, resources/permissions replacing submenu-keyed security) is non-trivial and out of scope for this document. Concrete near-term path: the Insurance portal, built largely from scratch — legacy submenu → resources row → permissions → capability_permissions mapping → submenu.resource_id. Existing Center/Provider screens exposing the same object are re-pointed at the same resources row rather than duplicating it (§13.9 — see Roadmap Phase 9).
Partitioned, sampled decision logging (§13.5) and materialized caches (§13.3) are the primary levers keeping storage and write volume bounded as request volume scales — governed by the same staleness budget and retention-per-country policy defined above.
Front matter, §24.1-24.2, §27.7 — three administrative levels. The Super Admin sets the functional ceiling (never delegated); the Country Admin freely composes its country's offer under that ceiling; the Center/Site Admin operates the entity day-to-day. “We never block the Country Admin a priori. We report it.”
“Structure of entities-relations” overview, Parts II-V — Country isolates data and carries brands; Center/Site/Provider/Association form the operational core; Reservation, Plan, Organization and Insurance attach to it; Locking can suspend any of them from above.
§26.3-26.7.10, Annex A.1 — every access decision, whatever the actor or resource, is resolved by one engine, in one fixed order, stopping at the first rule that applies. No business module implements its own permission logic.
§15-18 (Provider), §19-21 (Association) — four entry modes converge on one independent, single-country Provider profile; associations move through application/invitation → consent → active → suspended/withdrawn, gating calendars and visibility.
§22-23 — life cycle (draft → complete → published) and activation status are independent axes; automatic publication requires no manual step, and Patient visibility requires both axes plus an active association and no lock.
Front matter “Two income models”, §36-38 — flat-rate subscriptions and booking commissions are cumulative and never touch platform funds; country-scoped pricing is bounded by Super-Admin charging points and can be frozen by pricing/catalog locks.
Stand up the three-level administrative model and the functional-ceiling registry before any business entity exists, since the doctrine — “the Super Admin sets the ceiling, the Country Admin composes under it” — gates everything downstream (front matter, §24.1, §27.7.2).
Build multi-country isolation, white-label branding, geolocation and the Center/Site hierarchy — the structural backbone described as the transverse framework before any concrete entity (Parts II-III).
Implement the four Provider-creation modes and the Association lifecycle that gates calendars and visibility, plus the clinical-practice module referenced immediately after (Part IV, IVa, V).
Deliver the full central engine described in Annex A.1 as the unified resolution chain for every right in the system, together with the entity life-cycle/visibility rules and the 3-level delegation & hierarchical-locking model (§22-27, Annex A.1).
Build the calendar, appointment and overbooking engine the document itself calls the functional core, together with the medical acts/reasons catalogue it depends on for booking context (Part VIII-IX).
Implement the interim Insurance/third-party-payer model and the multi-channel notification system, while explicitly carrying forward the document's own caveat that Insurance (§30) is “developed separately and intended to be migrated later” (Annex A.6: High — structural).
Deliver Patient, Center and Organization subscription/access control together with multi-country pricing, currency and billing — Part XIII alone spans roughly a third of the entire specification (§36-39).
Close out the platform's non-functional guarantees: full audit traceability, break-glass hardening, data security/compliance and the explicit non-functional requirements (Part XI).
Assemble the three portals against the by-then-complete capability set, following the document's own consolidated-coverage cross-reference of every prior Part (Part XIV-XV).
Track the items Annex A.6 explicitly defers to product/legal arbitration rather than treating them as omissions — building against them now would be premature given they are marked unresolved in the source document itself.
/api/v{major}/{resource}[/{id}][/{sub-resource}][/{id}][/{action}] — codifies the majority form the catalogue already follows, resolves the places it contradicted itself, gives one decision rule per question.This tab documents the standard; the panel below is what actually changed in this catalog to comply with it.
| Category | Count |
|---|---|
| API paths renamed | 19 |
| New endpoints added | 2 (GET /professionals/me, GET /jobs/{jobId}) |
| Endpoints converted to the async job-resource pattern | 5 |
| Event names corrected | 7 distinct renames (4 entity.* → 12 per-aggregate events) |
| Permission actions folded away | 14 (73 → 59 distinct actions) |
| State machines corrected | 8 reviewed, 5 with structural fixes (appointment, subscription, verification, unregistered center, invitation) |
| Column-level comments added | 1997 / 2270 columns (88.0%) |
Full rename table, event/permission fold rationale, and state-machine citations are in the Architecture v5 tab (Open Questions & ADR panels) and in the API catalog itself — every renamed/converted endpoint carries a note explaining the change.
Two source documents are reproduced here as living reference: the API naming convention v1.0 (the rules) and the Domain naming register v1.0 (who owns which resource, and where two domains currently reach into the same root). Where the register says "the catalogue currently does X", read that as the state before v4.2.0 — the fixes panel above says what changed. Where the two source documents disagree with each other (see §16 below), that's flagged, not silently resolved.
One sentence to remember: the URL is kebab-case, the wire is camelCase, the database is snake_case. Translation happens at the persistence boundary and never leaks upward.
| Layer | Convention | Example |
|---|---|---|
| Path segment | kebab-case, lowercase | /act-configurations |
| Path parameter | {camelCase} in braces | {localActTypeId} |
| Query parameter | camelCase | ?pageSize=50 |
| JSON field | camelCase | rescheduledFromId |
| Enum value | SCREAMING_SNAKE | BY_PROVIDER |
| Error code | SCREAMING_SNAKE | VERSION_MISMATCH |
| Header | X-Kebab-Case | X-Idempotency-Key |
| Table & column | snake_case, plural table | appointment_status_history |
| Domain event | aggregate.pastTense | appointment.confirmed |
| Permission | RESOURCE+ACTION | APPOINTMENT+DECIDE |
| Service | kebab-case | center-provider |
Identifier language is US English. The product ships in French; the API does not — a francophone UI is a translation layer applied at the edge, never a naming input.
/api/v{major}/{resource}[/{id}][/{sub-resource}][/{id}][/{action}]
Resources are plural nouns, never a verb, never singular. Two exceptions: singletons (a child that can only ever have one instance takes the singular — /centers/{centerId}/subscription) and uncountable nouns (/staff, /pricing, /audit).
Collections hang off their parent; items are addressed by their own global ID.
✓ GET /centers/{centerId}/sites (scoped list + create) — ✓ GET /sites/{siteId} (the item itself) — ✗ GET /centers/{centerId}/sites/{siteId} (parent adds nothing once the child has its own global id).
Exception — composite keys: when the relation has no ID of its own and genuinely needs every parent segment to identify a row, deep nesting is correct: DELETE /centers/{centerId}/professionals/{professionalId}/site-assignments/{siteId} — three parameters, legitimate, because the assignment row has no ID of its own.
Parameters carry their entity's name — write {invoiceId}, never a bare {id}.
/admin/brands and /brands are the same resource seen through different permissions — authorization belongs in the token and the policy engine, not the URL. One carve-out: /public/* is legitimate for genuinely unauthenticated, cacheable, SEO-facing reads (a real infrastructure difference: CDN in front, no auth middleware, different rate limits).
Standard CRUD carries no verb (the HTTP method is the verb). Everything else is POST + an imperative verb: ✓ POST /appointments/{id}/cancel — ✗ /cancellation (noun) — ✗ /do-cancel (filler).
| Verb | Who acts | Body carries |
|---|---|---|
decide | An authority rules on a pending request | { decision, reason } |
respond | The subject accepts or declines something offered to them | { response } |
transition | A state machine moves; target state is explicit | { toState } |
resolve | A dispute or conflict is closed out | { outcome, note } |
| Suffix | Meaning | Returns |
|---|---|---|
/resolve | Walk a config hierarchy, return the effective value | the resolved object |
/check | Evaluate a policy, no side effect | { allowed, reason } |
/preview | Dry run — what would happen if I sent this | the projected result |
/simulate | Run a rules engine against sample input | evaluation trace |
/duplicate | Deep-copy the resource | 201 + the new resource |
/upload-url | Mint a pre-signed PUT | { url, fields, expiresAt } |
/download-url | Mint a pre-signed GET | { url, expiresAt } |
/export | Start an async bulk read | 202 + job handle |
/import | Start an async bulk write | 202 + job handle |
/bulk | Batched write over many items | 207 multi-status |
⚠ Known nuance, not fixed here: /resolve has two meanings across §05 (decision verb, POST) and §06 (reserved verb, GET) — both already coexist in this catalog (e.g. POST /disputes/{id}/resolve vs. GET /pricing/resolve). In practice, HTTP method + resource context disambiguate cleanly. Flagged as feedback for the convention document's own next revision (see open question OQ-N2).
Anything that can exceed a request timeout returns 202 immediately with a first-class, pollable resource:
POST /commission-statements/generate → 202 · Location: /jobs/{jobId} · { jobId, status: "QUEUED", pollAfterMs }
GET /jobs/{jobId} → { status: "RUNNING|SUCCEEDED|FAILED", progress, result, error }
One /jobs namespace for the whole platform, not one per service — applied in v4.2.0 to 5 endpoints (commission-statement generation, audit export, 3 bulk imports). See ADR-N2 for how ownership is resolved without a shared table or a new microservice.
| Keep | Retire | Why |
|---|---|---|
/reference/{referentialKind} | /config/center-types, /config/professional-types, /config/countries | Generic form already handles every kind |
/discovery/centers/{slug} | /profiles/centers/{slug} | "discovery" names the actual use case |
/access/check | /auth/evaluate | Authorization, not authentication |
/overbooking-configs/{scopeKind}/{scopeId} | /centers/{id}/overbooking-config, /professionals/{id}/overbooking-config | Polymorphic form covers every scope in one route |
/availability/{subjectType}/{subjectId} | /professionals/{id}/availability, /centers/{id}/availability | Availability is a query across a subject, not a field on it |
When one resource attaches to several parent types, use an explicit discriminator pair rather than forking the path: /overbooking-configs/{scopeKind}/{scopeId}, /subscribers/{subscriberType}/{subscriberId}/entitlements, /audit/entities/{entityType}/{entityId} — and, as of v4.2.0, /availability/{subjectType}/{subjectId}. Discriminator values are SCREAMING_SNAKE enums drawn from a single shared list: CENTER, SITE, PROFESSIONAL, ORGANIZATION.
me alias/patients/me/… resolves from the token, never accepts an id, and returns 403 not 404 when the caller isn't that kind of actor (a 404 would leak whether the record exists). Extended in v4.2.0 to /professionals/me, joining the existing /accounts/me and /patients/me.
{ "id": "uuid", "publicId": "uuid", "countryId": "BF", …, "createdAt": "…Z", "updatedAt": "…Z", "version": 0 }
| Suffix | Type | Example |
|---|---|---|
| Id | Single UUID FK | agendaId |
| Ids | Array of UUIDs | siteIds |
| At | ISO 8601 instant, UTC, Z | startsAt |
| On | Calendar date, no time | expiresOn |
| Count | Integer tally | memberCount |
| Code | Stable string identifier | countryCode |
| Url | Absolute URL | downloadUrl |
| Amount | Money — always paired with currency | totalAmount |
Kind vs. Type: Type = real business taxonomy (centerType, actType). Kind = internal plumbing discriminator (scopeKind, referentialKind). Booleans are never negated: ✓ isOverbooking, hasActiveSubscription — ✗ notPublished, disabled.
All instants ISO 8601 / UTC / Z. Where local wall-clock matters (it always does in booking), carry the zone as a sibling field — siteTimezone. Cursor pagination, not offset: { data: […], pagination: { cursor, nextCursor, pageSize, hasMore } }. Repeating a filter param means OR; different params mean AND.
{ "error": { "code": "VERSION_MISMATCH", "message": "…", "details": […], "traceId": "uuid" } }
Codes are SCREAMING_SNAKE, stable forever, never reused with a new meaning. Every POST a client might retry accepts X-Idempotency-Key.
Major version in the path, nothing else. Removing/renaming a field, narrowing a type, or a new required field needs v3; adding an optional field, a response field, an endpoint, or (documented-tolerant) an enum value ships into v2.
{aggregate}.{pastTense}, lowercase, dot-separated, aggregate singular. If POST /appointments/{id}/cancel exists, the event is appointment.cancelled — never booking.cancelled, never appointment.cancel.
Fixed in v4.2.0 (register §05): entity.* (4 events, named no real aggregate) split per-aggregate into 12 events with corrected tense; clinical.corrected → clinical_entry.corrected; appointment.no_show → appointment.marked_no_show (was reading as a state, not an occurrence); completeness_rules.updated → completeness_rule.updated (plural aggregate).
The catalogue historically held two parameter styles — {braces} (OpenAPI, the majority) and :colons (Express routing syntax leaking into specs). This catalog already uses {braces} exclusively.
The catalogue uses a permission action vocabulary of RESOURCE+ACTION strings. Core tiers, always available:
| Tier | Actions | Rule |
|---|---|---|
| Core | VIEW · CREATE · UPDATE · DELETE · MANAGE | Always available. MANAGE implies the other four. |
| Scoped | VIEW_SELF · UPDATE_SELF | Same action, narrowed to the caller's own record. |
| Lifecycle | ACTIVATE · ARCHIVE · SUBMIT · PUBLISH · RETIRE | State transitions on a versioned or approvable entity. |
| Decision | DECIDE · RESPOND · RESOLVE · TRANSITION | The four approval verbs above. |
| Delegation | GRANT · REVOKE · SUSPEND | Conferring or withdrawing rights on someone else's behalf. |
| Bulk | IMPORT · EXPORT · MERGE | Operations over many records at once. |
| Bespoke | SIGN · CERTIFY · CHECK_IN · … | Allowed only where the action is legally/clinically distinct and a reviewer would genuinely grant it separately. |
The test: a bespoke action earns its place if you can name a real role that gets it while being denied UPDATE on the same resource. PRESCRIPTION+SIGN passes (a secretary drafts, only a prescriber signs); MODULE+COMPOSE and ORGANIZATION+MANAGE_BRANCHES don't — they're MANAGE wearing a hat.
Applied in v4.2.0: 14 of the 73 original actions (52 of them used by exactly one resource) folded away as failing this test — see the Architecture v5 tab's Open Questions for the full fold list and the reasoning for what was deliberately not folded further (most singles passed the test on a real, spec-grounded actor distinction — collapsing them into MANAGE/UPDATE would trade away real, auditable granularity that a healthcare RBAC system needs).
Every endpoint declares exactly one. Scope is what separates SuperAdmin from Center from Site from Provider — not the URL, and not the role name.