Aller au contenu

Control Plane — Technical Architecture

Module: Control Plane (CP)
Product: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite
Version: 1.0
Date: 2026-02-27
Author: ARCHITECT (Claude Code)
Input documents:
- docs/prd/control-plane-prd.md (PRD+FSD v1.0) - docs/prd/platform-vision-prd.md (Platform Vision PRD v1.0) - docs/architecture/platform-saas-blueprint.md (SaaS Blueprint v2) - docs/standards/*.md (all 5 coding standards)

--

Table of Contents

  1. System Context
  2. Module Decomposition
  3. Data Model
  4. API Design
  5. Multi-Tenant Strategy
  6. Offline & Sync Architecture
  7. Sequence Diagrams
  8. Security Architecture
  9. Performance Considerations
  10. Migration Strategy
  11. Integration Points
  12. Frontend Design System Recommendation
  13. Vertical Slice Decomposition
  14. Technical Risks & Mitigations

1. System Context

1.1 Role in the Platform

The Control Plane is the foundation layer of the ALTARYS platform. It is the SaaS operator layer (borrowed from the AWS SaaS Builder Toolkit architecture). Every sellable HR/Finance module in the Application Plane depends on it.

graph TB
    subgraph "Control Plane (com.altarys.papillon.platform.*)"
        TENANT[Tenant Module]
        IDENTITY[Identity Module]
        BILLING[Billing Module]
        ORG[Organization Module]
        AUDIT[Audit Module]
        NOTIF[Notification Module]
    end

    subgraph "Application Plane (com.altarys.papillon.modules.*)"
        ABS[Absence Management]
        ATT[QR Attendance]
        PAY[Payroll]
        BUD[Budget Lines]
        CMT[Commitment]
        EXP[Expense Management]
        CRM[Internal CRM]
        HSE[HSE]
        PERF[Performance]
        GPEC[Skills & GPEC]
        LEARN[Learning]
        ATS[Recruitment]
    end

    subgraph "External Services"
        KC[Keycloak]
        PG[(PostgreSQL)]
        REDIS[(Redis)]
        MINIO[MinIO]
        EMAIL[Email Provider]
    end

    subgraph "Frontends"
        PAP[Papillon HR Suite<br/>frontend/]
        ENT[Enterprise HR Suite<br/>frontend-enterprise/]
        ADM[Admin Console<br/>admin/]
    end

    ABS --> TENANT
    ABS --> ORG
    ABS --> AUDIT
    ABS --> NOTIF
    ATT --> TENANT
    ATT --> ORG
    PAY --> TENANT
    PAY --> ORG
    PAY --> BILLING
    BUD --> TENANT
    BUD --> ORG
    CRM -.->|Channel B event| TENANT

    IDENTITY --> KC
    TENANT --> PG
    TENANT --> REDIS
    TENANT --> MINIO
    NOTIF --> EMAIL
    AUDIT --> PG

    PAP --> TENANT
    PAP --> IDENTITY
    ENT --> TENANT
    ENT --> IDENTITY
    ADM --> TENANT
    ADM --> IDENTITY
    ADM --> BILLING

1.2 What the Control Plane Provides to the Platform

Capability Consuming Modules Mechanism
Tenant resolution (JWT → tenant_id → TenantContext) ALL ScopedValue, Spring Security filter
Authentication (Keycloak JWT) ALL Spring Security, token exchange
Authorization (RBAC permission check) ALL Cached permission matrix, server-side validation
Module gating (is module active for this tenant?) ALL Redis-cached tenant config, API interceptor
Employee entity (baseline record) Absence, Attendance, Payroll, all REST API + Application Events
Org structure (departments, positions, hierarchy) Budget, Commitment, Reporting, all REST API + Application Events
Country config (tax rates, CNPS, leave types, holidays) Payroll, Absence REST API, Caffeine-cached
Audit trail (immutable logging) ALL Application Events (audit module as consumer per ADR-21)
Notification service (in-app + email) ALL NotificationService + WebSocket
Manager hierarchy (N+1 chain, Default Approver) Absence, Commitment, Expense REST API + ManagerChanged event

1.3 What the Control Plane Does NOT Do

  • Process leave requests (Absence Management)
  • Compute payroll (Payroll module)
  • Manage full employee dossiers (HR Core, post-MVP)
  • Enforce AUDCIF Art. 22 period locking / hash chains (Accounting module)
  • Provide document versioning or retention (Advanced Document Management)

2. Module Decomposition

The Control Plane is decomposed into 6 Spring Modulith modules, each with a single bounded context and distinct reason to change.

backend/src/main/java/com/altarys/platform/
├── tenant/              ← Tenant lifecycle, provisioning, settings, country config, module gating
│   ├── api/
│   ├── domain/
│   ├── service/
│   ├── repository/
│   ├── event/
│   ├── provisioning/    ← Async provisioning pipeline steps
│   └── config/
├── identity/            ← Users, roles, permissions, Keycloak integration
│   ├── api/
│   ├── domain/
│   ├── service/
│   ├── repository/
│   ├── keycloak/        ← Keycloak admin client wrapper
│   └── config/
├── billing/             ← Subscriptions, invoices, payments, pricing engine
│   ├── api/
│   ├── domain/
│   ├── service/
│   ├── repository/
│   ├── payment/         ← PaymentProvider adapter layer
│   └── config/
├── organization/        ← Org units, positions, categories, contracts, employees
│   ├── api/
│   ├── domain/
│   ├── service/
│   ├── repository/
│   └── config/
├── audit/               ← Immutable audit trail, shared service
│   ├── api/
│   ├── domain/
│   ├── service/
│   ├── repository/
│   └── config/
└── notification/        ← In-app + email, templates, WebSocket broadcast
    ├── api/
    ├── domain/
    ├── service/
    ├── repository/
    ├── websocket/       ← STOMP + SockJS configuration
    └── config/

Module Boundary Rules

Module Owns DataSource Publishes Events Consumes Events
tenant Tenant, TenantModule, TenantSettings, CountryConfig, provisioning pipeline Platform DB TenantProvisioned, TenantStatusChanged, ModuleActivated/Deactivated ProspectConvertedToTenant (from CRM)
identity User, UserTenantMembership, Role, Permission Platform DB UserRoleChanged TenantProvisioned (create Keycloak client + admin user)
billing Invoice, Payment, PricingSegment, PackRule, TenantInvoiceCounter Both (reads Tenant from platform, writes Invoice to tenant) PaymentConfirmed TenantStatusChanged (trigger billing status sync)
organization Employee, OrgUnit, Position, EmployeeCategory, ContractType, ManagerHistory, MatriculeCounter Tenant DB EmployeeCreated, EmployeeUpdated, EmployeeStatusChanged, OrgUnitCreated, OrgUnitDeactivated, PositionCreated, ManagerChanged TenantProvisioned (seed org structure)
audit AuditEntry Tenant DB (none — sink only) All mutation events from all modules
notification Notification, NotificationTemplate Tenant DB (notifications), Platform DB (templates) (none) Any module can call NotificationService

Inter-Module Communication

All inter-module communication uses Spring Application Events (in-process, same JVM) for read-only data access and notifications. Never direct method calls across module boundaries. (See ADR-14: default pattern is event-driven read models; Java interfaces only for atomic sync operations like budget mutations.)

// tenant module publishes
applicationEventPublisher.publishEvent(new TenantProvisioned(
    tenantId, countryCode, dbProfile, subscribedModules
));

// identity module listens
@EventListener
public void onTenantProvisioned(TenantProvisioned event) {
    keycloakService.createTenantClient(event.tenantId());
    keycloakService.createAdminUser(event.tenantId(), event.adminEmail());
    roleService.seedDefaultRoles(event.tenantId());
}

// organization module listens
@EventListener
public void onTenantProvisioned(TenantProvisioned event) {
    orgStructureService.seedDefaults(event.tenantId(), event.countryCode());
}

3. Data Model

3.1 Entity Scoping Strategy

Decision: Platform DB + Tenant DB separation (defense-in-depth: tenant_id column retained even in Profile C).

Scope Where Stored tenant_id column? Examples
Platform-level Platform DB No Tenant, User, UserTenantMembership, Role, CountryConfig
Tenant-scoped Tenant Data Space Yes (always, even Profile C) Employee, OrgUnit, Position, Invoice, AuditEntry, Notification
Reference data Platform DB No (country_code scoped) CountryTaxBracket, CountrySocialContribution, PublicHoliday

For MVP (Profile A): both Platform DB and Tenant Data Space are the same physical PostgreSQL database, but logically separated by schema (platform schema vs tenant schema). The dual DataSource abstraction exists from day one.

3.2 Entity-Relationship Diagram — Platform DB

erDiagram
    TENANT {
        uuid id PK
        varchar name
        varchar country_code
        varchar status
        varchar db_profile
        varchar onboarding_channel
        uuid salesperson_id
        timestamp created_date
        timestamp updated_date
        int version
    }

    USER_ACCOUNT {
        uuid id PK
        varchar email UK
        varchar full_name
        varchar phone
        varchar status
        uuid home_tenant_id FK
        timestamp created_date
        timestamp last_login_date
        int version
    }

    USER_TENANT_MEMBERSHIP {
        uuid id PK
        uuid user_id FK
        uuid tenant_id FK
        timestamp joined_date
    }

    MEMBERSHIP_ROLE {
        uuid membership_id FK
        uuid role_id FK
    }

    ROLE {
        uuid id PK
        varchar name
        varchar code
        boolean is_system
        int version
    }

    PERMISSION {
        uuid id PK
        uuid role_id FK
        varchar permission_code
    }

    TENANT_MODULE {
        uuid tenant_id FK
        varchar module_code
        varchar status
        timestamp activated_date
        timestamp deactivated_date
    }

    TENANT_SETTINGS {
        uuid tenant_id PK_FK
        varchar locale
        varchar timezone
        varchar matricule_prefix
        int fiscal_year_start_month
        int working_days_per_week
        decimal working_hours_per_day
        varchar currency
        varchar logo_url
        uuid default_approver_user_id FK
        varchar atmp_risk_class
        int version
    }

    COUNTRY_TAX_BRACKET {
        uuid id PK
        varchar country_code
        int fiscal_year
        bigint bracket_lower
        bigint bracket_upper
        decimal rate_percent
        date effective_date
        boolean is_verified
    }

    COUNTRY_SOCIAL_CONTRIBUTION {
        uuid id PK
        varchar country_code
        int fiscal_year
        varchar branch_code
        varchar branch_name
        decimal employer_rate
        decimal employee_rate
        bigint ceiling_monthly
        varchar risk_class
        date effective_date
        boolean is_verified
    }

    COUNTRY_SECTOR_SALARY_GRID {
        uuid id PK
        varchar country_code
        varchar sector_code
        varchar sector_name
        varchar category_code
        bigint minimum_salary_monthly
        date effective_date
    }

    PUBLIC_HOLIDAY {
        uuid id PK
        varchar country_code
        int year
        date holiday_date
        varchar name
    }

    %% Note: PUBLIC_HOLIDAY in platform DB contains reference holidays per country
    %% (seeded by Control Plane). Tenant-specific overrides are stored in absence module's
    %% tenant DB table with source='TENANT_OVERRIDE' field.

    NOTIFICATION_TEMPLATE {
        uuid id PK
        varchar template_code UK
        varchar channel
        varchar subject_template
        text body_template
        varchar locale
        int version
    }

    TENANT ||--o{ TENANT_MODULE : "subscribes"
    TENANT ||--|| TENANT_SETTINGS : "has"
    TENANT ||--o{ USER_TENANT_MEMBERSHIP : "has members"
    USER_ACCOUNT ||--o{ USER_TENANT_MEMBERSHIP : "belongs to"
    USER_ACCOUNT }o--|| TENANT : "home tenant"
    USER_TENANT_MEMBERSHIP ||--o{ MEMBERSHIP_ROLE : "assigned"
    ROLE ||--o{ MEMBERSHIP_ROLE : "granted via"
    ROLE ||--o{ PERMISSION : "includes"
    TENANT_SETTINGS }o--|| USER_ACCOUNT : "default approver"

3.3 Entity-Relationship Diagram — Tenant Data Space

erDiagram
    EMPLOYEE {
        uuid id PK
        uuid tenant_id
        varchar matricule UK
        varchar full_name
        varchar email
        varchar phone
        uuid contract_type_id FK
        uuid org_unit_id FK
        uuid position_id FK
        uuid category_id FK
        date hire_date
        uuid manager_id FK
        varchar status
        date status_effective_date
        timestamp created_date
        timestamp updated_date
        int version
    }

    ORG_UNIT {
        uuid id PK
        uuid tenant_id
        varchar name
        varchar type
        uuid parent_id FK
        varchar status
        timestamp created_date
        timestamp deactivated_date
        int version
    }

    POSITION {
        uuid id PK
        uuid tenant_id
        varchar title
        varchar description
        uuid category_id FK
        uuid department_id FK
        varchar status
        boolean is_custom
        int version
    }

    EMPLOYEE_CATEGORY {
        uuid id PK
        uuid tenant_id
        varchar country_code
        varchar name
        varchar code
        varchar description
        int sort_order
        boolean is_system
        int version
    }

    CONTRACT_TYPE {
        uuid id PK
        uuid tenant_id
        varchar country_code
        varchar name
        varchar code
        int max_duration_months
        varchar description
        boolean is_system
        varchar status
        int version
    }

    MANAGER_HISTORY {
        uuid id PK
        uuid tenant_id
        uuid employee_id FK
        uuid manager_id FK
        date start_date
        date end_date
    }

    TENANT_MATRICULE_COUNTER {
        uuid tenant_id PK
        varchar prefix
        int padding
        int last_number
    }

    INVOICE {
        uuid id PK
        uuid tenant_id
        varchar invoice_number UK
        date invoice_date
        varchar seller_name
        varchar seller_rccm
        varchar seller_nif
        varchar seller_address
        varchar buyer_name
        varchar buyer_rccm
        varchar buyer_nif
        bigint subtotal_ht
        bigint tva_amount
        bigint total_ttc
        varchar qr_code
        varchar fne_fiscal_number
        varchar payment_method
        varchar payment_status
        boolean is_proforma
        timestamp created_date
        int version
    }

    INVOICE_LINE {
        uuid id PK
        uuid invoice_id FK
        uuid tenant_id
        varchar module_code
        varchar description
        int quantity
        bigint unit_price
        bigint total
    }

    PAYMENT {
        uuid id PK
        uuid tenant_id
        uuid invoice_id FK
        varchar provider
        bigint amount
        varchar currency
        varchar status
        varchar provider_reference
        uuid confirmed_by_user_id
        timestamp created_date
        int version
    }

    TENANT_INVOICE_COUNTER {
        uuid tenant_id
        int fiscal_year
        int last_number
    }

    AUDIT_ENTRY {
        uuid id PK
        uuid tenant_id
        uuid user_id
        timestamp timestamp
        varchar entity_type
        uuid entity_id
        varchar module_code
        varchar action
        jsonb before_value
        jsonb after_value
        jsonb metadata
    }

    NOTIFICATION {
        uuid id PK
        uuid tenant_id
        uuid recipient_user_id
        varchar template_code
        varchar channel
        varchar priority
        boolean is_read
        jsonb params
        timestamp created_date
        timestamp read_date
    }

    EMPLOYEE }o--|| ORG_UNIT : "assigned to"
    EMPLOYEE }o--|| POSITION : "holds"
    EMPLOYEE }o--|| EMPLOYEE_CATEGORY : "classified as"
    EMPLOYEE }o--|| CONTRACT_TYPE : "employed under"
    EMPLOYEE }o--o| EMPLOYEE : "managed by (N+1)"
    EMPLOYEE ||--o{ MANAGER_HISTORY : "history"
    ORG_UNIT }o--o| ORG_UNIT : "child of"
    POSITION }o--|| EMPLOYEE_CATEGORY : "requires category"
    POSITION }o--o| ORG_UNIT : "default dept"
    INVOICE ||--o{ INVOICE_LINE : "contains"
    INVOICE ||--o{ PAYMENT : "paid by"

3.4 Indexing Strategy

All tenant-scoped tables use composite indexes with tenant_id as the leading column.

-- Employee lookups (hot path for all modules)
CREATE INDEX idx_employee_tenant_status ON employee (tenant_id, status);
CREATE INDEX idx_employee_tenant_org ON employee (tenant_id, org_unit_id);
CREATE INDEX idx_employee_tenant_manager ON employee (tenant_id, manager_id);
CREATE INDEX idx_employee_tenant_matricule ON employee (tenant_id, matricule);

-- Org unit tree traversal
CREATE INDEX idx_org_unit_tenant_parent ON org_unit (tenant_id, parent_id);
CREATE INDEX idx_org_unit_tenant_type ON org_unit (tenant_id, type, status);

-- Invoice lookups (billing)
CREATE INDEX idx_invoice_tenant_status ON invoice (tenant_id, payment_status);
CREATE INDEX idx_invoice_tenant_date ON invoice (tenant_id, invoice_date DESC);
CREATE UNIQUE INDEX idx_invoice_number ON invoice (tenant_id, invoice_number);

-- Audit trail (time-partitioned table — index per partition)
CREATE INDEX idx_audit_tenant_time ON audit_entry (tenant_id, timestamp DESC);
CREATE INDEX idx_audit_tenant_entity ON audit_entry (tenant_id, entity_type, entity_id);

-- Notification (bell icon query)
CREATE INDEX idx_notification_recipient_unread
  ON notification (tenant_id, recipient_user_id, is_read)
  WHERE is_read = FALSE;

-- Platform DB indexes
CREATE UNIQUE INDEX idx_user_email ON user_account (email);
CREATE INDEX idx_membership_user ON user_tenant_membership (user_id);
CREATE INDEX idx_membership_tenant ON user_tenant_membership (tenant_id);
CREATE INDEX idx_tenant_module ON tenant_module (tenant_id, module_code);

3.5 Key Data Model Decisions

Decision Choice Rationale
Primary keys UUID v7 (client-generated for user entities, server-generated for system entities) Offline-compatible, time-sortable, no ID conflicts
Financial amounts BIGINT storing centimes (XOF has 0 decimals, so 15 000 XOF = 15000) Avoids floating-point. BigDecimal in Java, BIGINT in PostgreSQL. Scale = 0 for XOF/XAF.
Optimistic locking version INT column on all mutable entities Offline sync conflict detection (409 Conflict)
Soft deletes Status field (ACTIVE → TERMINATED / INACTIVE / DELETED), never hard delete OHADA 10-year retention, audit trail integrity
Timestamps TIMESTAMPTZ (UTC in DB), displayed in tenant timezone CI is GMT+0, no DST complications
Employee full_name Single full_name field, not first/last split West African naming conventions don't fit first/last model
Matricule prefix 3-6 uppercase letters derived from company name, configurable Premium feel, cabinet comptable differentiation

4. API Design

4.1 URL Conventions

Base: /api/v1/cp/<resource>           (tenant-scoped Control Plane endpoints)
      /api/v1/public/<resource>       (unauthenticated endpoints)
      /api/v1/admin/<resource>        (ALTARYS Platform Admin endpoints)
      /api/v1/auth/<resource>         (authentication endpoints)

Tenant resolved from JWT tenant_id claim — never in URL path.

4.2 Response Envelope

All API responses use a consistent envelope:

{
  "data": { ... },
  "meta": {
    "cursor": "eyJpZCI6...",
    "hasMore": true,
    "total": 142
  },
  "errors": null
}

Error response:

{
  "data": null,
  "meta": null,
  "errors": [
    {
      "code": "VALIDATION_ERROR",
      "field": "email",
      "message": "Format d'email invalide."
    }
  ]
}

4.3 Endpoint Catalog

4.3.1 Public Endpoints (No Authentication)

Method Path Description Rate Limit
POST /api/v1/public/onboarding Self-registration (Channel A) 3/IP/hour
GET /api/v1/public/onboarding/{tenantId}/verify?token=... Email verification
GET /api/v1/public/onboarding/{tenantId}/provisioning-status Poll provisioning progress

POST /api/v1/public/onboarding

// Request
{
  "companyName": "SOCOPRIM SARL",
  "countryCode": "CI",
  "declaredEmployeeCount": 45,
  "adminEmail": "[email protected]",
  "adminFullName": "Kouamé Assi",
  "adminPhone": "+2250707123456",
  "rccm": "CI-ABJ-2024-B-12345",
  "selectedModules": ["ABSMGT", "QRCONTR"],
  "requestTrial": true,
  "captchaToken": "hcaptcha_response_token"
}

// Response 202 Accepted
{
  "data": {
    "tenantId": "019537a2-...",
    "status": "PENDING_VERIFICATION",
    "message": "Un email de vérification a été envoyé à [email protected]"
  }
}

4.3.2 Authentication Endpoints

Method Path Description
POST /api/v1/auth/login Keycloak-proxied login → JWT
POST /api/v1/auth/refresh Refresh access token
POST /api/v1/auth/switch-tenant Token exchange for tenant switching
GET /api/v1/auth/my-permissions Get permission matrix for current user's roles
GET /api/v1/auth/my-tenants List tenant memberships (for tenant switcher)

POST /api/v1/auth/switch-tenant

// Request
{
  "targetTenantId": "019537b1-..."
}

// Response 200
{
  "data": {
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "eyJhbGciOi...",
    "tenantId": "019537b1-...",
    "tenantName": "Cabinet Comptable Kouassi",
    "roles": ["Comptable"]
  }
}

GET /api/v1/auth/my-permissions

// Response 200
{
  "data": {
    "permissions": [
      "cp.employee.create",
      "cp.employee.read",
      "cp.employee.update",
      "absence.leave_request.create",
      "absence.leave_request.approve",
      "payroll.payslip.read"
    ],
    "tenantStatus": "ACTIVE",
    "activeModules": ["ABSMGT", "QRCONTR", "PAYROL"]
  }
}

4.3.3 Tenant Management Endpoints

Method Path Description Permission
GET /api/v1/cp/tenant Get current tenant info Any authenticated user
PUT /api/v1/cp/tenant Update tenant company info cp.tenant.manage
GET /api/v1/cp/tenant/settings Get tenant settings Any authenticated user
PUT /api/v1/cp/tenant/settings Update tenant settings cp.tenant.manage
GET /api/v1/cp/tenant/modules Get module statuses Any authenticated user
POST /api/v1/cp/tenant/modules/{code}/activate Activate a module cp.tenant.manage
POST /api/v1/cp/tenant/modules/{code}/deactivate Deactivate a module cp.tenant.manage

4.3.4 User Management Endpoints

Method Path Description Permission
GET /api/v1/cp/users List users in current tenant cp.user.read
POST /api/v1/cp/users/invite Invite user by email cp.user.manage
GET /api/v1/cp/users/{id} Get user detail cp.user.read
PUT /api/v1/cp/users/{id}/roles Update user roles cp.user.manage
POST /api/v1/cp/users/{id}/disable Disable user cp.user.manage

POST /api/v1/cp/users/invite

// Request
{
  "email": "[email protected]",
  "fullName": "Aya Koné",
  "roleIds": ["019537c0-..."]
}

// Response 201
{
  "data": {
    "userId": "019537c1-...",
    "status": "INVITED",
    "message": "Invitation envoyée à [email protected]"
  }
}

4.3.5 Organization & Employee Endpoints

Method Path Description Permission
GET /api/v1/cp/org-units List org units (flat or tree) cp.org.read
POST /api/v1/cp/org-units Create org unit cp.org.manage
PUT /api/v1/cp/org-units/{id} Update org unit cp.org.manage
POST /api/v1/cp/org-units/{id}/deactivate Deactivate org unit cp.org.manage
GET /api/v1/cp/org-units/{id}/tree Get subtree from this unit cp.org.read
GET /api/v1/cp/positions List positions cp.org.read
POST /api/v1/cp/positions Create position cp.org.manage
GET /api/v1/cp/categories List employee categories cp.org.read
GET /api/v1/cp/contract-types List contract types cp.org.read
GET /api/v1/cp/employees List employees (cursor-paginated) cp.employee.read
POST /api/v1/cp/employees Create employee cp.employee.create
GET /api/v1/cp/employees/{id} Get employee detail cp.employee.read
PUT /api/v1/cp/employees/{id} Update employee cp.employee.update
GET /api/v1/cp/employees/{id}/manager Get employee's manager cp.employee.read
PUT /api/v1/cp/employees/{id}/manager Change employee's manager cp.employee.update
GET /api/v1/cp/employees?org_unit_id={id} Filter employees by department cp.employee.read

POST /api/v1/cp/employees

// Request (UUID v7 generated client-side)
{
  "id": "019537d0-...",
  "fullName": "Traoré Ibrahim",
  "email": "[email protected]",
  "phone": "+2250505987654",
  "contractTypeId": "019537d1-...",
  "orgUnitId": "019537d2-...",
  "positionId": "019537d3-...",
  "hireDate": "2026-03-01",
  "managerId": "019537d4-..."
}

// Response 201
{
  "data": {
    "id": "019537d0-...",
    "matricule": "SOCO-2026-03-007",
    "fullName": "Traoré Ibrahim",
    "status": "PRE_HIRE",
    "statusEffectiveDate": "2026-03-01"
  }
}

4.3.6 Country Configuration Endpoints

Method Path Description Permission
GET /api/v1/cp/country-config/{countryCode}/tax-brackets Get ITS brackets Any authenticated
GET /api/v1/cp/country-config/{countryCode}/social-contributions Get CNPS rates Any authenticated
GET /api/v1/cp/country-config/{countryCode}/leave-types Get leave type reference Any authenticated
GET /api/v1/cp/country-config/{countryCode}/holidays/{year} Get public holidays Any authenticated
GET /api/v1/cp/country-config/{countryCode}/categories Get employee categories Any authenticated
GET /api/v1/cp/country-config/{countryCode}/salary-grids Get sector salary grids Any authenticated

4.3.7 Billing Endpoints

Method Path Description Permission
GET /api/v1/cp/billing/subscription Get current subscription details cp.billing.read
GET /api/v1/cp/billing/invoices List invoices cp.billing.read
GET /api/v1/cp/billing/invoices/{id} Get invoice detail cp.billing.read
POST /api/v1/cp/billing/invoices/{id}/pay Initiate payment cp.billing.manage
GET /api/v1/cp/billing/invoices/{id}/pdf Download invoice PDF cp.billing.read

4.3.8 Audit & Notification Endpoints

Method Path Description Permission
GET /api/v1/cp/audit Query audit trail (filtered, paginated) cp.audit.read
GET /api/v1/cp/notifications List user's notifications Any authenticated
PUT /api/v1/cp/notifications/{id}/read Mark notification as read Any authenticated
PUT /api/v1/cp/notifications/read-all Mark all as read Any authenticated
GET /api/v1/cp/notifications/unread-count Get unread count (bell badge) Any authenticated

4.3.9 Admin Console Endpoints (Platform Admin Only)

Method Path Description
GET /api/v1/admin/tenants List all tenants (filtered, paginated)
GET /api/v1/admin/tenants/{id} Get tenant detail (cross-tenant view)
POST /api/v1/admin/tenants/{id}/suspend Manually suspend tenant
POST /api/v1/admin/tenants/{id}/reactivate Reactivate suspended tenant
POST /api/v1/admin/tenants/{id}/confirm-payment Confirm bank transfer payment
POST /api/v1/admin/tenants/{id}/module-override Grant module access override
GET /api/v1/admin/payments/pending Pending payments dashboard
GET /api/v1/admin/metrics/summary Platform metrics (MRR, tenant count, etc.)
GET /api/v1/admin/country-config/{countryCode} View country config
PUT /api/v1/admin/country-config/{countryCode}/tax-brackets Update tax brackets
PUT /api/v1/admin/country-config/{countryCode}/social-contributions Update social contributions

4.3.10 Internal API (Module-to-Module, Not Public)

Method Path Description Caller
POST /api/v1/internal/tenants/from-prospect Create tenant from CRM prospect CRM module (Channel B stub)
POST /api/v1/internal/notifications Publish notification from any module All modules
POST /api/v1/internal/audit Log audit entry from any module All modules

4.4 Pagination

Cursor-based pagination on all list endpoints (per API guidelines):

GET /api/v1/cp/employees?limit=20&cursor=eyJpZCI6...&sort=fullName,asc&status=ACTIVE&org_unit_id=019537d2-...

Cursor encodes the last row's sort key + id. Stable under concurrent writes (unlike offset-based).


5. Multi-Tenant Strategy

5.1 Tenant Context Propagation

HTTP Request
  → TenantResolutionFilter (Spring Security filter)
    → Extract tenant_id, country_code, user_id from JWT claims
    → ScopedValue.where(TENANT_CONTEXT, tenantInfo).run(() -> filterChain.doFilter(...))
      → TenantMdcFilter (logging: MDC.put("tenant_id", ...))
        → Controller → Service → Repository
          → Every query includes WHERE tenant_id = :tenantId
// Java 25 ScopedValue — immutable within scope, no leaking between virtual threads
public final class TenantContext {
    public static final ScopedValue<TenantInfo> CURRENT = ScopedValue.newInstance();

    public record TenantInfo(
        UUID tenantId,
        String countryCode,
        UUID userId,
        List<String> roles
    ) {}

    public static TenantInfo current() {
        return CURRENT.orElseThrow(() ->
            new IllegalStateException("TenantContext not set — request outside tenant scope"));
    }
}

5.2 Dual DataSource Configuration

@Configuration
public class DataSourceConfig {

    @Bean @Qualifier("platform")
    public DataSource platformDataSource(
            @Value("${altarys.datasource.platform.url}") String url,
            @Value("${altarys.datasource.platform.username}") String username,
            @Value("${altarys.datasource.platform.password}") String password) {
        return HikariDataSourceBuilder.create()
            .jdbcUrl(url).username(username).password(password)
            .maximumPoolSize(20)
            .build();
    }

    @Bean @Qualifier("tenant") @Primary
    public DataSource tenantDataSource(TenantDataSourceRouter router) {
        return router;
    }
}
// MVP: routes all tenants to the same DB
// Profile C: routes per tenant_id to different databases
public class TenantDataSourceRouter extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.current().tenantId();
    }
}

5.3 Four Profile Support (Design for BYO-DB, Simplify for Others)

Profile MVP? DataSource Behavior tenant_id in queries?
A: Shared DB tenantDataSource → same DB as platform Yes (mandatory)
C: DB-per-tenant tenantDataSource → dynamic routing to tenant's DB Yes (defense-in-depth)
B: Schema-per-tenant V2 tenantDataSource → same DB, SET search_path Yes (defense-in-depth)
D: BYO-DB V2 tenantDataSource → tenant-provided connection string Yes (defense-in-depth)

Profile A (MVP — Shared DB)

# application.yml
altarys:
  datasource:
    platform:
      url: jdbc:postgresql://localhost:5432/altarys_platform
    tenant:
      default-url: jdbc:postgresql://localhost:5432/altarys_platform  # same DB for MVP
  multi-tenant:
    default-profile: SHARED

Profile C (MVP — DB-per-tenant)

public class DbPerTenantDataSourceRouter extends AbstractRoutingDataSource {
    private final ConcurrentHashMap<UUID, DataSource> tenantPools = new ConcurrentHashMap<>();
    private final TenantDbConfigRepository configRepo;

    @Override
    protected DataSource determineTargetDataSource() {
        UUID tenantId = TenantContext.current().tenantId();
        return tenantPools.computeIfAbsent(tenantId, this::createPool);
    }

    private DataSource createPool(UUID tenantId) {
        TenantDbConfig config = configRepo.findByTenantId(tenantId);
        return HikariDataSourceBuilder.create()
            .jdbcUrl(config.jdbcUrl())
            .username(config.username())
            .password(config.decryptedPassword())
            .maximumPoolSize(config.poolSize())
            .build();
    }
}

5.4 Tenant-Aware Repository Pattern

Every repository method must include tenant_id. No exceptions. This is enforced by convention (code review) and by integration tests.

// Base interface — all tenant-scoped repositories extend this
public interface TenantScopedRepository<T> {

    @Query("SELECT * FROM #{#tableName} WHERE tenant_id = :tenantId AND id = :id")
    Optional<T> findByIdAndTenant(@Param("id") UUID id, @Param("tenantId") UUID tenantId);
}

// Concrete repository
public interface EmployeeRepository extends CrudRepository<Employee, UUID> {

    @Query("""
        SELECT * FROM employee
        WHERE tenant_id = :tenantId AND id = :id
        """)
    Optional<Employee> findByIdAndTenant(@Param("id") UUID id, @Param("tenantId") UUID tenantId);

    @Query("""
        SELECT * FROM employee
        WHERE tenant_id = :tenantId AND status IN (:statuses)
        ORDER BY full_name ASC
        """)
    List<Employee> findByTenantAndStatuses(
        @Param("tenantId") UUID tenantId,
        @Param("statuses") List<String> statuses);

    @Query("""
        SELECT * FROM employee
        WHERE tenant_id = :tenantId AND org_unit_id = :orgUnitId AND status = 'ACTIVE'
        ORDER BY full_name ASC
        """)
    List<Employee> findActiveByOrgUnit(
        @Param("tenantId") UUID tenantId,
        @Param("orgUnitId") UUID orgUnitId);
}

5.5 Row-Level Security (Defense-in-Depth for Profile A)

For Profile A (shared DB), PostgreSQL RLS adds a second layer of protection:

-- Enable RLS on all tenant-scoped tables
ALTER TABLE employee ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON employee
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Set before each query via connection callback
SET app.current_tenant_id = '019537a2-...';

This ensures that even if a developer forgets WHERE tenant_id = :tenantId in a custom query, the database itself blocks cross-tenant data access. Belt and suspenders.


6. Offline & Sync Architecture

6.1 Offline Scope for Control Plane

Feature Offline? Rationale
Employee creation ✅ Yes PRD FR-CP-054: form works offline, UUID v7 client-generated, matricule "Numéro en attente"
Employee list (read) ✅ Yes Cached in IndexedDB via TanStack Query
Org structure browse ✅ Yes Cached in IndexedDB (small dataset, rarely changes)
Position catalog browse ✅ Yes Cached in IndexedDB
Permission matrix ✅ Yes Cached in IndexedDB on login (for offline UI rendering)
Billing / subscription ❌ No Requires online (payment processing, invoice generation)
User management ❌ No Requires online (Keycloak integration, email sending)
Tenant settings ❌ No Requires online (infrequent admin action)
Audit trail query ❌ No Requires online (large dataset, server-side filtering)
Admin console ❌ No ALTARYS staff have reliable connectivity

6.2 Offline Write Queue

// frontend/src/hooks/useOfflineQueue.ts
interface QueuedOperation {
  id: string;           // UUID v4
  method: 'POST' | 'PUT' | 'DELETE';
  url: string;
  body: unknown;
  timestamp: number;
  retryCount: number;
  entityType: string;   // 'employee', 'org_unit', etc.
  entityId: string;     // UUID v7 of the entity
}

// Persisted in IndexedDB — survives app restart
// Processed FIFO when online
// Failed items: exponential backoff (max 3 retries)
// After max retries: surface to user ("Réessayer / Supprimer")

6.3 Employee Offline Creation Flow

sequenceDiagram
    participant User as HR Manager
    participant App as Papillon PWA
    participant IDB as IndexedDB
    participant SW as Service Worker
    participant API as Backend API
    participant DB as PostgreSQL

    User->>App: Fill employee form
    App->>App: Generate UUID v7 (client-side)
    App->>IDB: Save employee (status=PENDING_SYNC)
    App-->>User: ✅ "Employé créé" (matricule: "Numéro en attente")

    Note over App,API: Connectivity restored

    SW->>IDB: Read offline queue (FIFO)
    SW->>API: POST /api/v1/cp/employees {id: uuid7, ...}
    API->>API: Validate, check duplicates
    API->>API: Lock matricule counter (FOR UPDATE)
    API->>API: Assign matricule "SOCO-2026-03-007"
    API->>DB: INSERT employee
    API->>DB: Publish EmployeeCreated
    API-->>SW: 201 Created {matricule: "SOCO-2026-03-007"}
    SW->>IDB: Update employee (matricule="SOCO-2026-03-007", status=SYNCED)
    App-->>User: 🔔 "Synchronisation terminée — matricule: SOCO-2026-03-007"

6.4 Conflict Resolution

Optimistic locking via version field on all mutable entities:

  1. Client sends PUT /api/v1/cp/employees/{id} with version: 3
  2. Server checks: current DB version = 3? → proceed. Version = 4? → 409 Conflict
  3. On 409: frontend shows side-by-side comparison:
  4. "Votre version" (from IndexedDB)
  5. "Version serveur" (from 409 response body)
  6. Three buttons: "Garder ma version" / "Garder la version serveur" / "Fusionner"
  7. For simple fields (name, phone): last-write-wins with user confirmation
  8. For complex entities (employee with manager change + status change): require manual merge

6.5 Sync Status UI

Global header indicator:
  🟢 Synchronisé                     (all synced)
  🟠 2 modifications en attente      (pending sync)
  🟠⟳ Synchronisation en cours...    (actively syncing)
  🔴 Échec de synchronisation        (sync failed — action needed)

Per-record indicator on recently modified items:
  ☁️ "Synchronisé"
  ⏳ "En attente de synchronisation"
  ❌ "Erreur — Réessayer"

Footer text: "Dernière synchronisation : il y a 2 minutes"

7. Sequence Diagrams

7.1 Channel A — Self-Registration + Provisioning

sequenceDiagram
    participant User as Prospect
    participant FE as Papillon Frontend
    participant API as SelfOnboardingController
    participant HC as hCaptcha
    participant TS as TenantService
    participant DB as Platform DB
    participant EV as Event Bus
    participant PS as ProvisioningSteps
    participant KC as Keycloak
    participant MIO as MinIO
    participant EM as Email Service
    participant FEP as Frontend (polling)

    User->>FE: Fill registration form
    FE->>API: POST /api/v1/public/onboarding (+ captchaToken)
    API->>HC: Verify captcha
    HC-->>API: ✅ Valid
    API->>TS: Validate (email unique, phone unique, fuzzy name check)
    TS->>DB: Check duplicates
    DB-->>TS: No exact match (fuzzy flagged for ops)
    TS->>DB: INSERT tenant (status=PENDING_VERIFICATION)
    TS->>EM: Send verification email (token, 24h expiry)
    API-->>FE: 202 {tenantId, status: PENDING_VERIFICATION}
    FE-->>User: "Vérifiez votre email"

    User->>FE: Click verification link
    FE->>API: GET /verify?token=...
    API->>TS: Verify token
    TS->>DB: UPDATE tenant status

    alt Trial selected
        TS->>DB: status → TRIAL
    else No trial
        TS->>DB: status → PAYMENT_REQUIRED
    end

    TS->>EV: Publish TenantCreated

    Note over EV,PS: Async provisioning pipeline

    EV->>PS: Step 1: Select DB profile (auto by employee count)
    PS->>DB: Record db_profile on tenant
    EV->>PS: Step 2: Provision DB (Profile A: no-op)
    EV->>PS: Step 3: Seed country config (ITS, CNPS, holidays, SYSCOHADA)
    PS->>DB: INSERT reference data with tenant_id
    EV->>PS: Step 4: Seed org structure (root Entity + 6 depts)
    PS->>DB: INSERT org_units
    EV->>PS: Step 5: Configure Keycloak
    PS->>KC: Create client + admin user + default roles
    EV->>PS: Step 6: Init module gating
    PS->>DB: INSERT tenant_modules (selected modules → ACTIVE)
    EV->>PS: Step 7: Create MinIO bucket
    PS->>MIO: Create tenant bucket
    EV->>PS: Step 8: Send welcome email
    PS->>EM: Send activation email
    EV->>PS: Step 9: Finalize
    PS->>DB: Update provisioning_status = COMPLETED
    PS->>EV: Publish TenantProvisioned

    FEP->>API: GET /provisioning-status (polling)
    API-->>FEP: {step: 9/9, status: COMPLETED}
    FEP-->>User: "Votre espace est prêt !"

7.2 Multi-Tenant Login + Tenant Switching

sequenceDiagram
    participant User as Accountant (Cabinet)
    participant FE as Frontend
    participant API as AuthController
    participant KC as Keycloak
    participant DB as Platform DB
    participant REDIS as Redis

    User->>FE: Enter email + password
    FE->>KC: POST /realms/altarys/protocol/openid-connect/token
    KC-->>FE: JWT (user_id, home_tenant_id, roles[])

    FE->>API: GET /api/v1/auth/my-tenants
    API->>DB: SELECT memberships WHERE user_id = :userId
    DB-->>API: [{tenantId: A, name: "Client 1", logo}, {tenantId: B, name: "Client 2", logo}, ...]
    API-->>FE: Tenant list with names + logos

    FE-->>User: Home tenant dashboard + "Mes entreprises" thumbnails

    User->>FE: Click "Client 2" thumbnail
    FE->>API: POST /api/v1/auth/switch-tenant {targetTenantId: B}
    API->>DB: Validate user has membership in tenant B
    API->>KC: Token exchange (new JWT for tenant B)
    KC-->>API: New JWT (user_id, tenant_id=B, roles_for_B[])
    API->>REDIS: Invalidate old tenant config cache
    API-->>FE: {accessToken, tenantName: "Client 2", roles: ["Comptable"]}

    FE->>FE: Replace JWT in memory
    FE->>FE: Flush TanStack Query cache
    FE->>FE: Re-fetch permissions for tenant B
    FE-->>User: Tenant B dashboard

7.3 Monthly Invoice Generation + Payment

sequenceDiagram
    participant CRON as Billing Scheduler
    participant BS as BillingService
    participant DB as Tenant DB
    participant PDB as Platform DB
    participant PP as PaymentProvider
    participant NS as NotificationService
    participant EM as Email

    CRON->>BS: Trigger monthly billing (tenants where billing_date = today)
    BS->>PDB: SELECT tenants due for billing

    loop Each tenant
        BS->>DB: COUNT active employees (status IN (ACTIVE, PRE_HIRE))
        BS->>BS: Determine pricing segment (S1-S6)
        BS->>PDB: SELECT active modules for tenant
        BS->>BS: Check pack rules → apply best discount
        BS->>BS: Calculate per-module prices + platform fee (S4-S6)
        BS->>BS: Calculate subtotal HT, TVA (18%), total TTC

        BS->>DB: Lock tenant_invoice_counters FOR UPDATE
        BS->>DB: INCREMENT last_number → RETURNING sequence
        BS->>DB: INSERT invoice (FAC-2026-{sequence}) + line items
        BS->>DB: INSERT invoice_lines

        BS->>NS: Send INVOICE_GENERATED notification
        NS->>EM: Email with invoice PDF attachment
        NS->>DB: INSERT notification (in-app)
    end

7.4 Provisioning Failure + Retry

sequenceDiagram
    participant EV as Event Bus
    participant PS as ProvisioningStep (Keycloak)
    participant KC as Keycloak
    participant DB as Platform DB
    participant ADMIN as Admin Console

    EV->>PS: Step 5: Configure Keycloak
    PS->>KC: Create client for tenant
    KC-->>PS: ❌ Connection timeout

    PS->>DB: Record step failure (attempt 1/3)
    PS->>PS: Wait 5 seconds (exponential backoff)
    PS->>KC: Retry: Create client for tenant
    KC-->>PS: ❌ Connection timeout

    PS->>DB: Record step failure (attempt 2/3)
    PS->>PS: Wait 25 seconds
    PS->>KC: Retry: Create client for tenant
    KC-->>PS: ❌ Connection timeout

    PS->>DB: Record step failure (attempt 3/3)
    PS->>DB: UPDATE tenant provisioning_status = FAILED
    PS->>DB: UPDATE tenant internal_status = PROVISIONING_FAILED
    PS->>ADMIN: Alert ALTARYS ops (notification + dashboard)
    Note over ADMIN: Manual intervention required

8. Security Architecture

8.1 Authentication Flow

User → Keycloak (OIDC) → JWT → Spring Security Filter → TenantContext → API

JWT Claims:
{
  "sub": "019537c1-...",          // user_id (UUID)
  "tenant_id": "019537a2-...",    // resolved tenant
  "country_code": "CI",
  "roles": ["DG", "Manager"],
  "iat": 1740600000,
  "exp": 1740600900               // 15-minute access token
}
  • Single Keycloak realm for all tenants (MVP)
  • Access token: 15 minutes. Refresh token: 7 days.
  • Token exchange for tenant switching (no re-authentication)
  • Password policy: min 8 chars, uppercase + lowercase + digit
  • Account lockout: 5 failed attempts → 15-minute cooldown

8.2 Authorization — Hybrid Permission Model

Layer 1: JWT carries roles[] (not individual permissions)
  → Lightweight token, fewer claims

Layer 2: On login, frontend fetches full permission matrix
  GET /api/v1/auth/my-permissions
  → Cached in Caffeine (backend) + IndexedDB (frontend)

Layer 3: Backend ALWAYS validates server-side
  → Never trusts frontend permission cache
  → @PreAuthorize or custom PermissionChecker

Layer 4: Module gating overrides role permissions
  → If module INACTIVE → all module permissions silently denied
  → If tenant SUSPENDED → all write permissions denied
// Permission check on every controller method
@RestController
@RequestMapping("/api/v1/cp/employees")
public class EmployeeController {

    @GetMapping
    @RequiresPermission("cp.employee.read")
    public ResponseEntity<ApiResponse<List<EmployeeDto>>> list(...) { ... }

    @PostMapping
    @RequiresPermission("cp.employee.create")
    @RequiresModuleActive("CP")         // module gating
    @RequiresTenantWritable             // tenant not suspended
    public ResponseEntity<ApiResponse<EmployeeDto>> create(...) { ... }
}

8.3 Default Roles & Permissions (MVP)

Role Scope Key Permissions
DG Tenant-wide cp.*, absence.*, attendance.*, payroll.*, billing.*, audit.read
RH Manager Tenant-wide cp.employee.*, cp.org.*, absence.*, attendance.*, payroll.read
Comptable Tenant-wide cp.employee.read, budget.*, commitment.*, expense.*, payroll.read
Employé Own data cp.employee.read.self, absence.leave_request.create.self, attendance.clock.self
Manager Direct reports cp.employee.read.team, absence.leave_request.approve.team

Permission format: {module_code}.{entity}.{action}[.scope] - Actions: create, read, update, delete, approve, export, manage - Scopes: (none) = tenant-wide, .self = own data, .team = direct reports

8.4 Public Endpoint Security

Threat Mitigation
Bot registrations hCaptcha on self-registration form
Email abuse Verification token (24h expiry), max 3 resends per 24h
Rate limiting 3 registrations per IP per hour
Enumeration attacks Generic response: "Si cette adresse existe, un email a été envoyé"
Duplicate detection Exact match on email/phone → BLOCK. Fuzzy company name → FLAG for ops.
Input abuse Jakarta Bean Validation on all fields

8.5 Audit Trail

Every data mutation across ALL modules produces an immutable audit entry:

@Service
public class AuditService {

    public void log(String action, String entityType, UUID entityId,
                    String moduleCode, Object beforeValue, Object afterValue) {
        TenantInfo tenant = TenantContext.current();
        AuditEntry entry = new AuditEntry(
            UuidCreator.getTimeOrderedEpoch(), // UUID v7 server-generated
            tenant.tenantId(),
            tenant.userId(),
            Instant.now(),
            entityType,
            entityId,
            moduleCode,
            action,
            objectMapper.valueToTree(beforeValue),
            objectMapper.valueToTree(afterValue),
            null // metadata
        );
        auditRepository.insert(entry);  // append-only, never update/delete
    }
}
  • Audit entries are immutable: no UPDATE, no DELETE, ever
  • Retention: 10 years minimum (AUDCIF Art. 24)
  • Tenant-scoped: tenant admins see only their audit trail
  • Platform-scoped: ALTARYS admin can query across tenants
  • Storage: time-partitioned table, monthly partitions, archived to MinIO after hot window

9. Performance Considerations

9.1 Caching Strategy

Data Cache TTL Invalidation Rationale
Permission matrix Caffeine (in-memory) 30 min UserRoleChanged Sub-ms lookup on hottest path (~5 checks/request)
Tenant config + module gating Redis 5 min TenantStatusChanged, ModuleActivated Consistency across instances, staleness = security risk
Country config (tax, CNPS) Caffeine (in-memory) 1 hour CountryConfigUpdated Rarely changes (annually), read-heavy
Org structure (tree) Caffeine (in-memory) 10 min OrgUnitCreated/Deactivated Small dataset, frequently read by all modules

9.2 Connection Pooling

# HikariCP settings
altarys:
  datasource:
    platform:
      maximum-pool-size: 20        # Platform DB (shared, relatively low traffic)
      minimum-idle: 5
      connection-timeout: 5000
      idle-timeout: 300000
    tenant:
      maximum-pool-size: 50        # Tenant DB (higher traffic, serves all modules)
      minimum-idle: 10
      connection-timeout: 5000

# Profile C: per-tenant pools
  per-tenant-pool:
    maximum-pool-size: 10          # Each tenant's dedicated DB pool
    minimum-idle: 2

9.3 Query Optimization

  • All tenant-scoped queries use composite indexes with tenant_id as leading column
  • Audit trail: time-partitioned → partition pruning eliminates 99% of data on time-range queries
  • Employee list: cursor-based pagination → stable O(1) page transitions
  • Org unit tree: single query with recursive CTE, cached in Caffeine
  • Notification unread count: partial index WHERE is_read = FALSE → sub-ms query
-- Recursive CTE for org unit tree (single query, not N+1)
WITH RECURSIVE org_tree AS (
    SELECT id, name, type, parent_id, 0 as depth
    FROM org_unit
    WHERE tenant_id = :tenantId AND parent_id IS NULL
    UNION ALL
    SELECT o.id, o.name, o.type, o.parent_id, ot.depth + 1
    FROM org_unit o
    JOIN org_tree ot ON o.parent_id = ot.id
    WHERE o.tenant_id = :tenantId AND o.status = 'ACTIVE'
)
SELECT * FROM org_tree ORDER BY depth, name;

9.4 Performance Targets (from NFR)

Metric Target How Achieved
Auth endpoint p95 < 500ms Caffeine permission cache (sub-ms), Redis tenant config (~0.5ms)
CRUD endpoint p95 < 1s Indexed queries + connection pooling
Page load on 3G < 2s Shadcn/ui Tailwind bundle (purged CSS ~15KB), lazy loading, Service Worker cache
Onboarding time < 5 min Simple form, async provisioning, progress UI
Provisioning (Profile A) < 30s No DB creation, just INSERT statements
Provisioning (Profile C) < 2 min Async pipeline, parallelizable steps

10. Migration Strategy

10.1 Flyway Configuration

backend/src/main/resources/
├── db/migration/
│   ├── platform/              ← Platform DB migrations
│   │   ├── V001__create_tenant.sql
│   │   ├── V002__create_user_account.sql
│   │   ├── V003__create_roles_permissions.sql
│   │   ├── V004__create_country_config.sql
│   │   └── V005__create_notification_templates.sql
│   └── tenant/                ← Tenant Data Space migrations
│       ├── V001__create_employee.sql
│       ├── V002__create_org_unit.sql
│       ├── V003__create_position_category.sql
│       ├── V004__create_invoice_payment.sql
│       ├── V005__create_audit_entry_partitioned.sql
│       ├── V006__create_notification.sql
│       └── V007__create_counters.sql

10.2 Migration Execution per Profile

Profile Platform DB Tenant DB
A: Shared Flyway runs platform/ migrations on startup Flyway runs tenant/ migrations on startup (same DB)
C: DB-per-tenant Flyway runs platform/ on startup Flyway runs tenant/ migrations on each tenant DB at provisioning time + on app startup for existing tenants
B: Schema-per-tenant Flyway runs platform/ on startup Flyway runs tenant/ per schema at provisioning + startup
D: BYO-DB Flyway runs platform/ on startup Flyway runs tenant/ on tenant's provided DB at provisioning + health check

10.3 Migration Rules

  1. Every migration is idempotent (can be re-run safely)
  2. Every migration has an undo script (V001__create_tenant.sql → U001__drop_tenant.sql)
  3. No data migrations in DDL scripts — separate V{n}__data_seed_{description}.sql
  4. Migrations tested against all 4 active profiles in CI (A, B, C, D)
  5. Zero-downtime migrations: use ALTER TABLE ... ADD COLUMN ... DEFAULT ... (not lock-heavy DDL)

11. Integration Points

11.1 Events Published by Control Plane

Event Source Module Payload Consumed By
TenantProvisioned tenant tenantId, countryCode, dbProfile, subscribedModules[] identity (Keycloak setup), organization (seed data), all modules (initialization)
TenantStatusChanged tenant tenantId, oldStatus, newStatus, reason All modules (enforce read-only/suspension)
TenantModuleActivated tenant tenantId, moduleCode, activatedDate Target module (initialization)
TenantModuleDeactivated tenant tenantId, moduleCode, deactivatedDate Target module (cleanup)
EmployeeCreated organization tenantId, employeeId, matricule, fullName, contractTypeCode, hireDate, categoryCode, orgUnitId audit, notification, absence, payroll
EmployeeUpdated organization employeeId, tenantId, changedFields[] All modules caching employee data
EmployeeStatusChanged organization employeeId, tenantId, oldStatus, newStatus, effectiveDate Absence, Attendance, Payroll
OrgUnitCreated organization unitId, tenantId, type, name, parentId Budget, Reporting
OrgUnitDeactivated organization unitId, tenantId, deactivatedDate Budget, Reporting
PositionCreated organization positionId, tenantId, title, categoryId GPEC (V2)
ManagerChanged organization employeeId, tenantId, oldManagerId, newManagerId, effectiveDate Absence, Commitment, Expense
UserRoleChanged identity userId, tenantId, newRoles[] Frontend (cache invalidation via WebSocket)
PaymentConfirmed billing tenantId, invoiceId, amount, method tenant (status transition)

11.2 Events Consumed by Control Plane

Event Source Module Handler
ProspectConvertedToTenant CRM (Channel B) tenant.provisioning → create tenant from prospect data

11.3 External Service Dependencies

Service Used By Failure Impact Fallback
Keycloak identity Cannot authenticate Cached JWT validation (short-term), 503 for new logins
PostgreSQL all Full outage None (critical dependency)
Redis tenant (caching) Degraded performance Fall through to DB query (Caffeine still works)
MinIO tenant (provisioning) Cannot create buckets, document upload fails Retry pipeline, manual intervention
Email provider notification Emails not sent Retry queue (RabbitMQ), in-app notification still works
hCaptcha public endpoints Cannot register Temporarily disable captcha with rate limiting fallback

12. Frontend Design System Recommendation

12.1 Decision: Shadcn/ui + Tailwind CSS

Criterion Shadcn/ui + Tailwind MUI Ant Design Metronic
Claude frontend-design compatibility ⭐⭐⭐ Native Tailwind output ⭐ Different styling system ⭐ Different styling system ⭐⭐⭐ Tailwind-based
Component richness ⭐⭐ Good (+ TanStack Table, Recharts) ⭐⭐⭐ Excellent (60+) ⭐⭐⭐ Excellent (100+) ⭐⭐ Template, not library
Offline-friendliness (bundle size) ⭐⭐⭐ ~15KB CSS purged ⭐ 80-150KB gzipped ⭐ 200KB+ gzipped ⭐⭐ Tailwind-purged
Customization depth ⭐⭐⭐ You own the code ⭐⭐ createTheme API (verbose) ⭐⭐ Design tokens ⭐⭐ Template code
Dual-brand support ⭐⭐⭐ CSS variables swap ⭐⭐ ThemeProvider ⭐⭐ ConfigProvider ⭐⭐ CSS variables
Community maturity ⭐⭐ Growing fast (2023+) ⭐⭐⭐ Mature (2014+) ⭐⭐⭐ Mature (2015+) ⭐ Smaller
Cost Free (MIT) Free (MIT) Free (MIT) Paid (~$50-100)

12.2 Architecture

packages/shared-components/
├── ui/                        ← shadcn/ui components (owned, copy-pasted)
│   ├── button.tsx
│   ├── input.tsx
│   ├── select.tsx
│   ├── dialog.tsx
│   ├── data-table.tsx         ← TanStack Table wrapper
│   ├── form.tsx               ← React Hook Form integration
│   ├── toast.tsx
│   └── ...
├── composites/                ← Higher-level components
│   ├── EntityForm.tsx         ← Reusable form shell (all modules)
│   ├── DataTablePage.tsx      ← List page layout (table + filters + pagination)
│   ├── ConfirmDialog.tsx
│   ├── SyncStatusBadge.tsx    ← Offline sync indicator
│   └── NotificationBell.tsx
├── themes/
│   ├── papillon.css           ← Warm amber/gold CSS variables
│   └── enterprise.css         ← Navy corporate CSS variables
├── tailwind.config.ts         ← Shared Tailwind preset
└── index.ts                   ← Public exports

12.3 Companion Libraries

Need Library Rationale
Data tables (dense, sortable, filterable) TanStack Table v8 Headless — styled with Tailwind, supports virtual rows for large datasets
Charts (dashboard metrics) Recharts Simple, declarative, lightweight (~40KB)
Forms + validation React Hook Form + Zod Per coding standards, offline-friendly
Date handling date-fns with fr locale Tree-shakeable (unlike Moment.js)
Icons Lucide React Consistent icon set, tree-shakeable

13. Vertical Slice Decomposition

Each slice is a thin, end-to-end implementable unit: API + business logic + persistence + UI. Ordered by dependency and business value.

Slice 1: TenantContext + Auth Foundation

Scope: ScopedValue TenantContext, Spring Security filter, Keycloak JWT validation, dual DataSource config, tenant-aware repository base pattern. Files to create/modify: - backend/.../infrastructure/tenant/TenantContext.java - backend/.../infrastructure/tenant/TenantResolutionFilter.java - backend/.../infrastructure/tenant/TenantMdcFilter.java - backend/.../infrastructure/datasource/DataSourceConfig.java - backend/.../infrastructure/datasource/TenantDataSourceRouter.java - backend/.../infrastructure/security/SecurityConfig.java - backend/.../infrastructure/security/JwtTenantExtractor.java - backend/src/main/resources/application.yml - Integration tests: tenant isolation verification Complexity: L Dependencies: None (foundational) Business value: Every other slice depends on this

Slice 2: Tenant CRUD + Lifecycle State Machine

Scope: Tenant entity, status transitions (10 states), TenantService, REST API for tenant info, tenant settings. Files to create/modify: - backend/.../platform/tenant/domain/Tenant.java - backend/.../platform/tenant/domain/TenantStatus.java (sealed interface/enum) - backend/.../platform/tenant/domain/TenantSettings.java - backend/.../platform/tenant/repository/TenantRepository.java - backend/.../platform/tenant/service/TenantService.java - backend/.../platform/tenant/service/TenantStateMachine.java - backend/.../platform/tenant/api/TenantController.java - backend/.../platform/tenant/api/TenantDto.java - Flyway: V001__create_tenant.sql, V002__create_tenant_settings.sql - Tests: state machine transitions, all 10 statuses Complexity: L Dependencies: Slice 1 Business value: Core platform entity — everything references Tenant

Slice 3: User + Role + Permission (Identity Module)

Scope: User entity, UserTenantMembership, Role, Permission, invitation flow, hybrid permission model. Files to create/modify: - backend/.../platform/identity/domain/User.java - backend/.../platform/identity/domain/Role.java - backend/.../platform/identity/domain/Permission.java - backend/.../platform/identity/domain/UserTenantMembership.java - backend/.../platform/identity/repository/UserRepository.java - backend/.../platform/identity/repository/RoleRepository.java - backend/.../platform/identity/service/UserService.java - backend/.../platform/identity/service/PermissionService.java - backend/.../platform/identity/service/PermissionCacheService.java (Caffeine) - backend/.../platform/identity/keycloak/KeycloakAdminService.java - backend/.../platform/identity/api/UserController.java - backend/.../platform/identity/api/AuthController.java - Flyway: V003__create_users_roles_permissions.sql - Tests: permission resolution, role assignment, multi-tenant user Complexity: L Dependencies: Slice 1, Slice 2 Business value: Authentication + authorization for entire platform

Slice 4: Audit Trail Service

Scope: AuditEntry entity (time-partitioned), AuditService (shared), query API. Files to create/modify: - backend/.../platform/audit/domain/AuditEntry.java - backend/.../platform/audit/repository/AuditRepository.java - backend/.../platform/audit/service/AuditService.java - backend/.../platform/audit/api/AuditController.java - Flyway: V005__create_audit_entry_partitioned.sql - Partition management job - Tests: immutability, tenant scoping, time-range queries Complexity: M Dependencies: Slice 1 Business value: Legal compliance (AUDCIF Art. 24), required by all mutation endpoints

Slice 5: Organization Structure + Employee Baseline

Scope: OrgUnit (tree), Position, EmployeeCategory, ContractType, Employee entity, matricule generation, manager hierarchy. Files to create/modify: - backend/.../platform/organization/domain/OrgUnit.java - backend/.../platform/organization/domain/Position.java - backend/.../platform/organization/domain/EmployeeCategory.java - backend/.../platform/organization/domain/ContractType.java - backend/.../platform/organization/domain/Employee.java - backend/.../platform/organization/domain/ManagerHistory.java - backend/.../platform/organization/repository/*.java - backend/.../platform/organization/service/OrgUnitService.java - backend/.../platform/organization/service/EmployeeService.java - backend/.../platform/organization/service/MatriculeService.java - backend/.../platform/organization/api/OrgUnitController.java - backend/.../platform/organization/api/EmployeeController.java - backend/.../platform/organization/api/PositionController.java - Flyway: V001__create_employee.sql through V003__create_position_category.sql (tenant migrations) - Tests: org tree traversal, employee CRUD, matricule uniqueness, manager hierarchy Complexity: L Dependencies: Slice 1, Slice 2, Slice 4 Business value: Every module references employees and org structure

Slice 6: Country Configuration Engine

Scope: Tax brackets, CNPS rates, leave types, public holidays, SYSCOHADA chart, sector salary grids. Admin CRUD + tenant read API. Files to create/modify: - backend/.../platform/tenant/domain/CountryTaxBracket.java - backend/.../platform/tenant/domain/CountrySocialContribution.java - backend/.../platform/tenant/domain/PublicHoliday.java - backend/.../platform/tenant/domain/CountrySectorSalaryGrid.java - backend/.../platform/tenant/repository/CountryConfigRepository.java - backend/.../platform/tenant/service/CountryConfigService.java - backend/.../platform/tenant/api/CountryConfigController.java - Flyway: V004__create_country_config.sql (platform migrations) - Seed data: CI ITS brackets, CNPS rates, holidays 2026-2027, categories, contract types, top 5 sector grids - Tests: rate lookup by country + fiscal year, is_verified flag Complexity: M Dependencies: Slice 1, Slice 2 Business value: Required by Payroll, Absence, all downstream modules

Slice 7: Module Gating

Scope: TenantModule entity, module status tracking, API + interceptor enforcement, auto-inclusion rules (BUDMGT), upsell data. Files to create/modify: - backend/.../platform/tenant/domain/TenantModule.java - backend/.../platform/tenant/repository/TenantModuleRepository.java - backend/.../platform/tenant/service/ModuleGatingService.java - backend/.../infrastructure/security/ModuleGatingInterceptor.java - Redis caching for module status - Tests: gating enforcement, BUDMGT auto-inclusion, suspended tenant write denial Complexity: M Dependencies: Slice 1, Slice 2 Business value: Revenue gating — modules work only for paying tenants

Slice 8: Self-Registration + Provisioning Pipeline (Channel A)

Scope: Public onboarding endpoint, hCaptcha, email verification, async provisioning pipeline (9 steps), provisioning status polling, DB profile auto-selection. Files to create/modify: - backend/.../platform/tenant/api/SelfOnboardingController.java - backend/.../platform/tenant/service/OnboardingService.java - backend/.../platform/tenant/provisioning/ProvisioningOrchestrator.java - backend/.../platform/tenant/provisioning/steps/DbProvisioningStep.java - backend/.../platform/tenant/provisioning/steps/DataSeedingStep.java - backend/.../platform/tenant/provisioning/steps/KeycloakSetupStep.java - backend/.../platform/tenant/provisioning/steps/OrgStructureSeedingStep.java - backend/.../platform/tenant/provisioning/steps/ModuleGatingStep.java - backend/.../platform/tenant/provisioning/steps/MinIoSetupStep.java - backend/.../platform/tenant/provisioning/steps/NotificationStep.java - backend/.../platform/tenant/service/ProfileSelector.java - backend/.../platform/tenant/service/DuplicateDetectionService.java - Frontend: registration form, email verification page, provisioning progress screen - Tests: full pipeline E2E, retry on failure, duplicate detection Complexity: L Dependencies: Slices 1-7 (integrates everything) Business value: Customer acquisition — this is how tenants enter the platform

Slice 9: Billing Engine

Scope: Invoice generation, pricing segments (S1-S6), pack detection, platform fee calculation, payment provider abstraction, manual payment confirmation. Files to create/modify: - backend/.../platform/billing/domain/Invoice.java - backend/.../platform/billing/domain/InvoiceLine.java - backend/.../platform/billing/domain/Payment.java - backend/.../platform/billing/domain/PricingSegment.java - backend/.../platform/billing/domain/PackRule.java - backend/.../platform/billing/repository/*.java - backend/.../platform/billing/service/BillingService.java - backend/.../platform/billing/service/PricingEngine.java - backend/.../platform/billing/service/InvoiceNumberGenerator.java - backend/.../platform/billing/payment/PaymentProvider.java (interface) - backend/.../platform/billing/payment/ManualPaymentProvider.java - backend/.../platform/billing/payment/MobileMoneyPaymentProvider.java (stub) - backend/.../platform/billing/api/BillingController.java - Flyway: V004__create_invoice_payment.sql, V007__create_counters.sql - Tests: segment determination, pack discount, FNE-compliant invoice numbering, platform fee formula Complexity: L Dependencies: Slices 1-5 (reads employee count, tenant info) Business value: Revenue — this is how ALTARYS gets paid

Slice 10: Notification Service + WebSocket

Scope: NotificationService (shared), in-app + email channels, WebSocket STOMP setup, notification bell API, email templates (10 MVP templates). Files to create/modify: - backend/.../platform/notification/domain/Notification.java - backend/.../platform/notification/domain/NotificationTemplate.java - backend/.../platform/notification/repository/*.java - backend/.../platform/notification/service/NotificationService.java - backend/.../platform/notification/service/EmailService.java - backend/.../platform/notification/service/TemplateRenderer.java - backend/.../platform/notification/websocket/WebSocketConfig.java - backend/.../platform/notification/websocket/NotificationBroadcaster.java - backend/.../platform/notification/api/NotificationController.java - Flyway: V005__create_notification_templates.sql (platform), V006__create_notification.sql (tenant) - Seed: 10 MVP email templates - Tests: WebSocket delivery, offline queuing, template rendering Complexity: M Dependencies: Slices 1-3 Business value: User engagement, critical for onboarding emails

Slice 11: Multi-Tenant User + Tenant Switching

Scope: Cabinet comptable flow — multi-tenant login, tenant switcher UI, home_tenant_id, token exchange, "Mes entreprises" thumbnail dashboard. Files to create/modify: - backend/.../platform/identity/service/TenantSwitchService.java - backend/.../platform/identity/api/AuthController.java (extend with switch-tenant, my-tenants) - Frontend: tenant switcher component, "Mes entreprises" dashboard section - Tests: token exchange, membership validation, cache invalidation on switch Complexity: M Dependencies: Slice 3 Business value: Critical for cabinet comptable persona (accountants managing multiple clients)

Slice 12: Admin Console (Separate Frontend)

Scope: Admin React app (desktop-only, no offline), tenant list, payment management, suspend/reactivate, module override, basic metrics. Files to create/modify: - admin/ directory (new React app, Vite + TypeScript) - Admin API endpoints (already defined in Slice 2, 9) - Tenant list page, tenant detail page, pending payments dashboard, metrics dashboard - Keycloak Platform Admin role setup Complexity: L Dependencies: Slices 1-10 Business value: ALTARYS operations — required to manage the platform

Slice 13: Channel B Stub (CRM Integration Interface)

Scope: Internal endpoint /api/v1/internal/tenants/from-prospect, service-to-service JWT auth, event contract for ProspectConvertedToTenant. Files to create/modify: - backend/.../platform/tenant/api/InternalTenantController.java - backend/.../platform/tenant/event/ProspectConvertedToTenant.java - backend/.../platform/tenant/service/CrmConversionListener.java - Tests: endpoint contract, event handling Complexity: S Dependencies: Slice 8 Business value: Enables CRM module (Sprint 3) to convert prospects to tenants

Summary Table

Slice Name Size Depends On Sprint Target
1 TenantContext + Auth Foundation L Sprint 1, Week 1
2 Tenant CRUD + Lifecycle L 1 Sprint 1, Week 1
3 User + Role + Permission L 1, 2 Sprint 1, Week 1-2
4 Audit Trail Service M 1 Sprint 1, Week 1
5 Organization + Employee L 1, 2, 4 Sprint 1, Week 2
6 Country Config Engine M 1, 2 Sprint 1, Week 2
7 Module Gating M 1, 2 Sprint 1, Week 2
8 Self-Registration + Provisioning L 1-7 Sprint 1, Week 2-3
9 Billing Engine L 1-5 Sprint 1, Week 3
10 Notification + WebSocket M 1-3 Sprint 1, Week 3
11 Multi-Tenant User + Switching M 3 Sprint 1, Week 3
12 Admin Console L 1-10 Sprint 1, Week 3 → Sprint 2
13 Channel B Stub S 8 Sprint 1, Week 3

14. Technical Risks & Mitigations

# Risk Impact Probability Mitigation
1 Forgotten tenant_id in a query → cross-tenant data leakage CRITICAL Medium TenantAwareRepository pattern enforced at every repository. RLS as defense-in-depth for Profile A. Integration tests verify isolation per entity. REVIEWER checklist item.
2 Keycloak unavailability → all authentication fails HIGH Low Cached JWT validation (public key cached locally). JWT has 15-min lifetime — short outage survivable. Health check with alerting.
3 ScopedValue (Java 25) immaturity with Spring Boot 4 MEDIUM Medium Thin adapter layer: if ScopedValue integration fails in specific contexts (e.g., @Async), fall back to ThreadLocal for those code paths only. Monitor Spring Boot 4.0.x release notes.
4 Provisioning pipeline partial failure → tenant stuck in limbo HIGH Medium Each step is idempotent and retryable. 3 retries with exponential backoff. PROVISIONING_FAILED status visible in admin console. Manual intervention procedure documented.
5 Audit table growth → DB size explosion at scale MEDIUM High (by design) Time-partitioned table. Monthly archival to MinIO cold storage. Hot window = 12-18 months. Monitoring on partition size.
6 FNE invoice numbering gap → legal non-compliance HIGH Low Single-transaction counter (FOR UPDATE + INSERT). No gaps possible unless DB crash mid-transaction (PostgreSQL WAL prevents this).
7 Offline sync conflicts at scale → data inconsistency MEDIUM Medium Optimistic locking (version field). Side-by-side conflict resolution UI. For employees: duplicate email/phone detection on sync.
8 Redis failure → degraded module gating performance LOW Low Caffeine fallback (in-memory, shorter TTL). Redis is a cache, not a source of truth — DB is always the authority.
9 Mobile Money provider instability (Africa infrastructure) HIGH High Provider-agnostic PaymentProvider interface. Manual payment fallback ALWAYS available. Bank transfer as alternative. Multiple providers can be active simultaneously.
10 Solo developer velocity → Control Plane takes longer than Sprint 1 MEDIUM High Vertical slices ordered by dependency. Slices 1-4 are the critical path (auth + tenant + audit). Slices 11-13 can defer to Sprint 2 without blocking Application Plane modules.
11 ARTCI filing not started → cannot legally process CI personal data CRITICAL High PRE-LAUNCH LEGAL BLOCKER. Must be filed before production launch. Does not block development. Tracked in OQ-CP-03.
12 Temporarily validated tax/CNPS rates → incorrect calculations HIGH Medium is_verified flag on every rate. Admin console highlights unverified rates in orange. Payroll module double-checks rates before computing. Must verify before Payroll goes live.

Appendix A: Architectural Decision Log

# Decision Choice Rationale Date
ADR-01 Module decomposition 6 Modulith modules (tenant, identity, billing, organization, audit, notification) Clean bounded contexts, manageable for solo dev 2026-02-27
ADR-02 TenantContext propagation ScopedValue (Java 25) Immutable within scope, virtual-thread safe, no context leaking 2026-02-27
ADR-03 Backend deployment Single Spring Boot deployable (Control Plane + Application Plane) Team size, operational simplicity, Spring Modulith boundaries 2026-02-27 (locked)
ADR-04 Entity scoping Platform DB + Tenant DB separation BYO-DB clean split, tenant owns their data, platform data stays on ALTARYS infra 2026-02-27
ADR-05 Audit trail storage Time-partitioned table in tenant data space Tenant ownership, partition pruning for 10-year scale, atomic archival 2026-02-27
ADR-06 Provisioning pipeline Async event-driven (9 steps) Non-blocking UX, independently retryable steps, AWS SBT pattern 2026-02-27
ADR-07 Caching strategy Redis (tenant config) + Caffeine (permissions, country config) Tiered by access pattern: speed for hot path, consistency for shared state 2026-02-27
ADR-08 Invoice numbering Per-tenant counter table, FOR UPDATE + single transaction FNE gap-free compliance, per-tenant lock scope, zero contention 2026-02-27
ADR-09 Employee matricule prefix Format: {PREFIX}-{YEAR}-{MM}-{SEQ}. Company-derived trigram (3-6 chars), tenant-scoped, fallback to EMP Premium feel, cabinet comptable differentiation 2026-02-27
ADR-10 Frontend design system Shadcn/ui + Tailwind CSS Claude frontend-design compatibility, small bundle, CSS variable theming for dual-brand 2026-02-27
ADR-11 Notification delivery Spring WebSocket + STOMP + SockJS SockJS fallback for African proxy networks, native Spring Security integration 2026-02-27
ADR-12 Dual DataSource Named DataSources with @Qualifier binding Explicit wiring, MVP both point to same DB, Profile C routing transparent 2026-02-27

Document version: 1.0 Cross-referenced with: docs/prd/control-plane-prd.md (v1.0), docs/architecture/platform-saas-blueprint.md (v2.0) Next step: DESIGNER personality creates docs/design/control-plane-design.md