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¶
- System Context
- Module Decomposition
- Data Model
- API Design
- Multi-Tenant Strategy
- Offline & Sync Architecture
- Sequence Diagrams
- Security Architecture
- Performance Considerations
- Migration Strategy
- Integration Points
- Frontend Design System Recommendation
- Vertical Slice Decomposition
- 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:
- Client sends
PUT /api/v1/cp/employees/{id}withversion: 3 - Server checks: current DB version = 3? → proceed. Version = 4? → 409 Conflict
- On 409: frontend shows side-by-side comparison:
- "Votre version" (from IndexedDB)
- "Version serveur" (from 409 response body)
- Three buttons: "Garder ma version" / "Garder la version serveur" / "Fusionner"
- For simple fields (name, phone): last-write-wins with user confirmation
- 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_idas 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¶
- Every migration is idempotent (can be re-run safely)
- Every migration has an undo script (V001__create_tenant.sql → U001__drop_tenant.sql)
- No data migrations in DDL scripts — separate V{n}__data_seed_{description}.sql
- Migrations tested against all 4 active profiles in CI (A, B, C, D)
- 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