Platform Vision — Shared Infrastructure Architecture¶
Scope: Cross-cutting concerns reused by ALL modules
Product: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite
Version: 1.0
Date: 2026-03-13
Author: ARCHITECT (Claude Code)
Input documents:
- docs/prd/platform-vision-prd.md (Platform Vision PRD v1.0)
- docs/architecture/platform-saas-blueprint.md (SaaS Blueprint v2)
- docs/architecture/control-plane-arch.md (Control Plane Architecture v1.0)
- docs/standards/*.md (all coding standards)
Table of Contents¶
- Purpose & Scope
- System Context
- TenantContext Propagation
- Dual DataSource & Repository Patterns
- Cross-Module Event Architecture
- API Response Envelope & Error Handling
- Module Gating
- Audit Trail Integration
- Offline & Sync Framework
- i18n Architecture
- API Versioning Strategy
- Security Architecture
- Multi-Tenant Strategy (All 4 DB Profiles)
- Flyway Migration Strategy
- Performance & Caching
- Shared Test Infrastructure
- Local Dev Environment
- Frontend Architecture
- Vertical Slice Decomposition
- Architectural Decision Records
- Technical Risks & Mitigations
1. Purpose & Scope¶
This document defines the shared infrastructure patterns that every module on the ALTARYS platform must use. It is NOT a per-module architecture — it is the foundation layer.
What this document covers: - TenantContext propagation (the reusable pattern, not CP-specific implementation) - Dual DataSource wiring and repository patterns for all modules - Cross-module event contracts and shared event module - API response envelope and global error handling - Module gating (activation checks) - Audit trail integration pattern - Offline sync framework for frontends - i18n architecture - API versioning strategy - Shared test infrastructure - Local dev environment (docker-compose)
What already exists (DO NOT duplicate — reference only):
- docs/architecture/control-plane-arch.md — Full CP architecture (Tenant, Identity, Billing, Organization, Audit, Notification modules)
- docs/architecture/platform-saas-blueprint.md — AWS SaaS Factory adaptation, 4 DB profiles, hosting strategy
Relationship to existing arch docs: Every per-module arch doc (control-plane, budget, absence, future modules) depends on the patterns defined here. When a module-specific arch doc and this document disagree, this document wins — it represents the latest cross-cutting decisions.
2. System Context¶
2.1 Two-Plane Architecture¶
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<br/>ABSMGT]
BUD[Budget Lines<br/>BUDMGT]
ATT[QR Attendance<br/>ATTMGT]
PAY[Payroll<br/>PAYMGT]
CMT[Commitment<br/>COMMIT]
EXP[Expense Mgmt<br/>EXPMGT]
COREHR[Core HR<br/>COREMGT]
PERF[Performance<br/>PERFMGT]
GPEC[Skills & GPEC<br/>GPECMGT]
ATS[Recruitment<br/>ATSMGT]
LEARN[Learning<br/>LRNMGT]
HSE[HSE<br/>HSEMGT]
end
subgraph "Shared Infrastructure"
EVENTS[Shared Events<br/>com.altarys.papillon.platform.events]
TESTSUP[Test Support<br/>com.altarys.papillon.testsupport]
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/)"]
SHARED["Shared Components<br/>(packages/shared-components/)"]
end
ABS & BUD & ATT & PAY --> TENANT
ABS & BUD & ATT & PAY --> ORG
ABS & BUD & ATT & PAY --> AUDIT
ABS & BUD & ATT & PAY --> NOTIF
IDENTITY --> KC
TENANT --> PG
TENANT --> REDIS
NOTIF --> EMAIL
PAP --> SHARED
ENT --> SHARED
2.2 Package Structure¶
backend/src/main/java/com/altarys/papillon/
├── platform/ # Control Plane
│ ├── config/ # Global: ApiResponse, GlobalExceptionHandler
│ ├── tenant/ # Tenant module
│ │ ├── config/ # TenantContext, DataSourceConfig
│ │ ├── domain/ # Tenant, TenantStatus, TenantStateMachine
│ │ ├── repository/ # TenantSettingsRepository
│ │ ├── service/ # TenantService (JdbcClient)
│ │ ├── api/ # Controllers + DTOs
│ │ └── event/ # Domain events
│ ├── identity/ # Identity module (auth, JWT)
│ ├── billing/ # Billing module
│ ├── organization/ # Organization module (employees, org units)
│ ├── audit/ # Audit module
│ └── notification/ # Notification module
├── modules/ # Application Plane
│ ├── absence/ # ABSMGT
│ ├── budget/ # BUDMGT
│ ├── attendance/ # ATTMGT
│ └── ... # Future modules
└── events/ # Shared event contracts (ADR-PV-02)
├── employee/ # EmployeeCreated, etc.
├── absence/ # LeaveApproved, etc.
├── budget/ # BudgetThresholdReached, etc.
├── billing/ # PaymentConfirmed, etc.
└── tenant/ # TenantProvisioned, etc.
3. TenantContext Propagation¶
ADR-PV-01: ScopedValue (Java 25) as primary + ThreadLocal fallback
3.1 TenantContext Record¶
// com.altarys.papillon.platform.tenant.config.TenantContext
public record TenantContext(
UUID tenantId,
String countryCode,
String dbProfile // "SHARED" | "SCHEMA" | "DATABASE" | "BYO"
) {}
3.2 TenantContextHolder¶
// com.altarys.papillon.platform.TenantContextHolder
public final class TenantContextHolder {
public static final ScopedValue<TenantContext> TENANT_CONTEXT = ScopedValue.newInstance();
private static final ThreadLocal<TenantContext> THREAD_LOCAL_FALLBACK = new ThreadLocal<>();
public static TenantContext current() {
if (TENANT_CONTEXT.isBound()) {
return TENANT_CONTEXT.get();
}
TenantContext ctx = THREAD_LOCAL_FALLBACK.get();
if (ctx == null) {
throw new IllegalStateException("No TenantContext available");
}
return ctx;
}
// For @Scheduled tasks, Flyway migration contexts, test fixtures
public static void setFallback(TenantContext ctx) {
THREAD_LOCAL_FALLBACK.set(ctx);
}
public static void clearFallback() {
THREAD_LOCAL_FALLBACK.remove();
}
}
3.3 Where TenantContext is Set¶
| Context | Mechanism | Set By |
|---|---|---|
| HTTP request | ScopedValue.where(TENANT_CONTEXT, ctx).run(...) |
TenantResolutionFilter |
@Scheduled tasks |
ThreadLocal fallback |
Scheduler wrapping code |
| Flyway migrations (Schema/DB/BYO profiles) | ThreadLocal fallback |
Provisioning pipeline |
| Tests | ThreadLocal fallback |
AbstractTenantIntegrationTest.withTenant() |
@Async methods |
ThreadLocal fallback (ScopedValue doesn't propagate across thread boundaries) |
Async task decorator |
3.4 Request Flow¶
sequenceDiagram
participant Client
participant Filter as TenantResolutionFilter
participant SV as ScopedValue
participant Controller
participant Service
participant Repository
Client->>Filter: Request + JWT
Filter->>Filter: Extract tenant_id from JWT claims
Filter->>Filter: Lookup db_profile from platform DB
Filter->>SV: ScopedValue.where(TENANT_CONTEXT, ctx)
SV->>Controller: .run(() -> filterChain.doFilter())
Controller->>Service: business logic
Service->>Repository: @Query with :tenantId
Repository->>Repository: TenantContextHolder.current().tenantId()
Note over Filter,Repository: ScopedValue immutable for entire request scope
3.5 Rules for ALL Modules¶
- Never create your own TenantContext mechanism — always use
TenantContextHolder.current() - Never pass tenantId as a method parameter through service layers — read it from TenantContextHolder
- Always include tenant_id in every SQL query — convention enforced by CI test (ADR-PV-03)
- ScopedValue is immutable — you cannot change the tenant mid-request (this is a feature, not a bug)
4. Dual DataSource & Repository Patterns¶
ADR-PV-03: Convention + RLS + CI test (three layers of tenant isolation)
4.1 Two Databases, One Application¶
graph LR
subgraph "Spring Boot Application"
PS[Platform Services<br/>JdbcClient]
TS[Tenant Services<br/>Spring Data JDBC]
end
subgraph "Platform DB"
PT[tenant]
PU[user_tenant_membership]
PB[pricing_segment]
PC[country_tax_bracket]
PM[tenant_module]
end
subgraph "Tenant DB"
TE[employee]
TO[org_unit]
TL[leave_request]
TB[budget_line]
TA[audit_entry]
end
PS -->|"@Qualifier('platformJdbcClient')"| PT
PS --> PU
PS --> PB
TS -->|"@Primary DataSource"| TE
TS --> TO
TS --> TL
TS --> TB
TS --> TA
4.2 DataSource Configuration¶
// com.altarys.papillon.platform.tenant.config.DataSourceConfig
@Configuration
public class DataSourceConfig {
// Platform DB — tenants, users, billing, country configs
@Bean
@Qualifier("platformDataSource")
DataSource platformDataSource(DataSourceProperties props) { ... }
@Bean
@Qualifier("platformJdbcClient")
JdbcClient platformJdbcClient(@Qualifier("platformDataSource") DataSource ds) {
return JdbcClient.create(ds);
}
// Tenant DB — employees, org units, module data [PRIMARY]
@Bean
@Primary
@Qualifier("tenantDataSource")
DataSource tenantDataSource(TenantDataSourceProperties props) { ... }
}
4.3 Decision Table: Which Data Access Pattern to Use¶
| Data Location | Pattern | Example |
|---|---|---|
| Platform DB | @Qualifier("platformJdbcClient") JdbcClient |
TenantService, ModuleGatingService |
| Tenant DB (CRUD) | Spring Data JDBC CrudRepository |
LeaveRequestRepository, BudgetLineRepository |
| Tenant DB (custom queries) | @Query annotation on repository interface |
findByTenantIdAndStatus(...) |
| Cross-DB read | Two separate calls (never a join) | Service reads platform config, then tenant data |
4.4 Critical Rules¶
- NEVER use
CrudRepositoryfor platform tables — Spring Data JDBC routes allCrudRepositoryto@Primary(tenant DB). Platform tables live on a different datasource. Use@Qualifier("platformJdbcClient") JdbcClientinstead. - NEVER use
Instantin SpEL@Queryexpressions — Spring Data JDBC SpEL serializesInstantto ISO 8601 string that PostgreSQL JDBC can't parse. UseOffsetDateTimein@Query, convert in a default method. - Every
@QueryMUST includeWHERE tenant_id = :tenantId— enforced by CI reflective scan. - No joins across datasources — if you need data from both, make two calls in the service layer.
4.5 Tenant ID Enforcement (Three Layers)¶
Layer 1: Convention
Every @Query annotation includes "WHERE tenant_id = :tenantId"
Developers review this in PRs
Layer 2: CI Test (automated)
A reflective test scans all @Query annotations across all modules
Fails if any query on a tenant DB table lacks tenant_id filter
Runs on every PR
Layer 3: PostgreSQL RLS (defense-in-depth, Profile A only)
ALTER TABLE leave_request ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON leave_request
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Set via: SET LOCAL app.current_tenant_id = '<tenant-uuid>';
Applied in TenantResolutionFilter for Profile A (shared DB)
5. Cross-Module Event Architecture¶
ADR-PV-02: Shared event module (
com.altarys.papillon.events) ADR-14: Event-driven read models as default; Java interfaces only for atomic sync
5.1 Shared Event Catalog¶
All cross-module event records live in a dedicated package that every module can depend on:
com.altarys.papillon.events/
├── employee/
│ ├── EmployeeCreated.java
│ ├── EmployeeUpdated.java
│ ├── EmployeeStatusChanged.java
│ ├── ManagerChanged.java
│ └── EmployeeReassigned.java
├── absence/
│ ├── LeaveApproved.java
│ ├── LeaveCancelled.java
│ ├── LeaveBalanceSettlement.java
│ └── LeaveRequestSubmittedNotification.java
├── budget/
│ ├── BudgetThresholdReached.java
│ ├── BudgetExhausted.java
│ ├── BudgetOverrideApproved.java
│ └── BudgetOverrideRejected.java
├── billing/
│ └── PaymentConfirmed.java
└── tenant/
├── TenantProvisioned.java
├── TenantStatusChanged.java
├── ModuleActivated.java
└── ModuleDeactivated.java
5.2 Event Contract Rules¶
- Events are Java records — immutable, serializable, no behavior
- Events carry IDs, not entities —
employeeId, notEmployeeobject. Consumer looks up if needed. - Events are published via
ApplicationEventPublisher— Spring Modulith handles persistence viaEVENT_PUBLICATIONtable - Consumers use
@ApplicationModuleListener— guarantees at-least-once delivery with automatic retry - Naming:
<Entity><Past-tense-verb>Event(e.g.,LeaveApproved,TenantProvisioned)
5.3 Event-Driven Read Models (Default Pattern)¶
When Module A needs data owned by Module B for read-only purposes:
sequenceDiagram
participant OrgModule as Organization Module
participant EventBus as Spring Application Events
participant AbsModule as Absence Module
participant Cache as CachedEmployeeData (Tenant DB)
OrgModule->>EventBus: publish(EmployeeCreated)
EventBus->>AbsModule: @ApplicationModuleListener
AbsModule->>Cache: INSERT cached_employee_data
Note over AbsModule: Future reads use local cache
AbsModule->>Cache: SELECT from cached_employee_data
Note over Cache: Eventual consistency (ms-level staleness)
When to use Java interfaces instead: ONLY for synchronous request-reply operations requiring atomic cross-module transactions. Currently, only BudgetMutationApi (BUDMGT) qualifies — for reserve/consume with immediate locking guarantee. New modules MUST NOT create Java interfaces for read-only data access.
5.4 Guaranteed Delivery¶
Spring Modulith's EVENT_PUBLICATION table (Flyway migration V002__create_event_publication.sql in tenant-migration) persists events before dispatch. If a listener fails:
- Event stays in
EVENT_PUBLICATIONwithcompletion_date = NULL - Spring Modulith retries automatically (configurable interval)
- After max retries, event is flagged for manual intervention
- Zero events lost — even across application restarts
6. API Response Envelope & Error Handling¶
ADR-PV-04: Global handler + module error codes
6.1 Standard Response Envelope¶
Every API response — success or error — uses the same envelope:
// com.altarys.papillon.platform.ApiResponse
public record ApiResponse<T>(
@Nullable T data,
@Nullable Object meta, // Pagination cursors, counts, etc.
@Nullable List<ApiError> errors
) {
public static <T> ApiResponse<T> ok(T data) { ... }
public static <T> ApiResponse<T> ok(T data, Object meta) { ... }
public static <T> ApiResponse<T> error(List<ApiError> errors) { ... }
}
// com.altarys.papillon.platform.config.ApiError
public record ApiError(
String code, // Module-prefixed: "ABSENCE_001"
String message, // Machine-readable description (NOT user-facing)
@Nullable String field // For validation errors
) {}
6.2 Success Response Example¶
{
"data": {
"id": "01905b6c-7e5d-7000-8000-000000000001",
"employeeId": "01905b6c-7e5d-7000-8000-000000000002",
"status": "APPROVED"
},
"meta": null,
"errors": null
}
6.3 Paginated Response Example (Cursor-Based)¶
{
"data": [ ... ],
"meta": {
"cursor": "eyJpZCI6IjAxOTA1YjZjLTdlNWQifQ==",
"hasNext": true,
"totalCount": 142
},
"errors": null
}
6.4 Error Response Example¶
{
"data": null,
"meta": null,
"errors": [
{
"code": "ABSENCE_001_OVERLAPPING_LEAVE",
"message": "Leave request overlaps with existing approved leave",
"field": null
}
]
}
6.5 Global Exception Handler¶
One @RestControllerAdvice (GlobalExceptionHandler) handles ALL exceptions platform-wide:
| Exception | HTTP Status | Error Code Pattern |
|---|---|---|
MethodArgumentNotValidException |
400 | VALIDATION_ERROR (per-field) |
IllegalArgumentException |
400 | BAD_REQUEST |
AuthenticationException |
401 | UNAUTHORIZED |
AccessDeniedException |
403 | FORBIDDEN |
TenantNotFoundException |
403 | TENANT_NOT_FOUND (403 not 404 — no info leak) |
ModuleNotActivatedException |
403 | MODULE_NOT_ACTIVE |
*NotFoundException |
404 | <MODULE>_<ENTITY>_NOT_FOUND |
OptimisticLockingFailureException |
409 | CONFLICT_VERSION_MISMATCH |
IllegalStateTransitionException |
422 | <MODULE>_INVALID_STATE_TRANSITION |
BusinessRuleViolationException |
422 | Module-specific code |
Exception (catch-all) |
500 | INTERNAL_ERROR |
6.6 Module Error Code Convention¶
Each module defines error codes with a consistent prefix:
ABSENCE_001_OVERLAPPING_LEAVE
ABSENCE_002_INSUFFICIENT_BALANCE
BUDGET_001_LINE_EXHAUSTED
BUDGET_002_TRANSFER_SAME_LINE
CP_001_TENANT_NOT_FOUND
CP_002_INVALID_STATE_TRANSITION
Pattern: <MODULE>_<NNN>_<DESCRIPTION> where NNN is sequential within the module.
Key rule: The message field in ApiError is machine-readable (English, for logs). The frontend maps code → user-facing French translation via i18n (see Section 10).
7. Module Gating¶
ADR-PV-08: Spring
ModuleGatingInterceptor(URL prefix → module code mapping)
7.1 How It Works¶
A single HandlerInterceptor registered globally checks whether the current tenant has activated the module before allowing any request through:
// com.altarys.papillon.platform.config.ModuleGatingInterceptor
@Component
public class ModuleGatingInterceptor implements HandlerInterceptor {
private static final Map<String, String> MODULE_MAP = Map.of(
"/absence", "ABSMGT",
"/budget", "BUDMGT",
"/attendance", "ATTMGT",
"/payroll", "PAYMGT",
"/commitment", "COMMIT",
"/expense", "EXPMGT"
// CP endpoints (/api/v1/cp/*) are NOT gated — always available
);
private final ModuleGatingService moduleGatingService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String path = request.getRequestURI();
// Extract module prefix from path: /api/v1/absence/... → /absence
String prefix = extractModulePrefix(path);
String moduleCode = MODULE_MAP.get(prefix);
if (moduleCode == null) return true; // CP or unknown → pass through
UUID tenantId = TenantContextHolder.current().tenantId();
if (!moduleGatingService.isModuleActive(tenantId, moduleCode)) {
throw new ModuleNotActivatedException(moduleCode);
}
return true;
}
}
7.2 ModuleGatingService¶
// com.altarys.papillon.platform.tenant.service.ModuleGatingService
@Service
public class ModuleGatingService {
@Cacheable(value = "module-activation", key = "#tenantId + ':' + #moduleCode")
public boolean isModuleActive(UUID tenantId, String moduleCode) {
// Query platform DB: SELECT status FROM tenant_module
// WHERE tenant_id = :tenantId AND module_code = :moduleCode
// Returns true if status = 'ACTIVE'
}
}
Cache: Redis with 5-minute TTL. Invalidated by ModuleActivated / ModuleDeactivated.
7.3 Frontend Integration¶
The frontend calls GET /api/v1/cp/tenant on login, which returns the tenant's active modules. The router only renders menu items and routes for active modules.
// frontend/src/lib/moduleGating.ts
export function isModuleActive(modules: TenantModule[], code: string): boolean {
return modules.some(m => m.code === code && m.status === 'ACTIVE');
}
8. Audit Trail Integration¶
ADR-PV-06: Application Events (audit module as event consumer)
8.1 Pattern¶
Modules do NOT call an audit service. They publish domain events (which they already do per ADR-14). The Audit module listens to those events and creates AuditEntry records.
sequenceDiagram
participant Module as Any Module (e.g., Absence)
participant EventBus as Spring Application Events
participant AuditMod as Audit Module
participant AuditDB as audit_entry (Tenant DB)
Module->>EventBus: publish(LeaveApproved)
EventBus->>AuditMod: @ApplicationModuleListener
AuditMod->>AuditMod: Extract entity type, entity ID, action, payload
AuditMod->>AuditDB: INSERT INTO audit_entry
Note over AuditDB: Time-partitioned table (ADR-10)
8.2 AuditEntry Schema¶
CREATE TABLE audit_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
entity_type VARCHAR(100) NOT NULL, -- "LeaveRequest", "BudgetLine"
entity_id UUID NOT NULL,
action VARCHAR(50) NOT NULL, -- "CREATED", "APPROVED", "DELETED"
user_id UUID, -- NULL for system actions (cron)
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
payload JSONB NOT NULL -- Serialized event
) PARTITION BY RANGE (timestamp);
8.3 Audit Listener Registration¶
The Audit module registers one @ApplicationModuleListener per event type it cares about. A base handler method reduces boilerplate:
// com.altarys.papillon.platform.audit.listener.AuditEventListener
@Component
public class AuditEventListener {
@ApplicationModuleListener
void on(LeaveApproved e) {
record(e.tenantId(), "LeaveRequest", e.requestId(), "APPROVED", e);
}
@ApplicationModuleListener
void on(BudgetTransferApproved e) {
record(e.tenantId(), "BudgetTransfer", e.transferId(), "APPROVED", e);
}
// ... one method per auditable event
private void record(UUID tenantId, String entityType, UUID entityId,
String action, Object event) {
// Serialize event to JSON, INSERT into audit_entry
}
}
8.4 Coverage Guarantee¶
Since ADR-14 requires all mutations to publish events, audit coverage = event coverage. A CI test can verify: "every module that publishes at least one event has a corresponding @ApplicationModuleListener in the audit module."
9. Offline & Sync Framework¶
ADR-PV-05: Shared
useOfflineQueue()hook inpackages/shared-components/
9.1 Architecture Overview¶
graph TB
subgraph "React Application"
FORM[Form Component]
HOOK[useOfflineQueue Hook]
STATUS[Sync Status Provider]
end
subgraph "Browser Storage"
IDB[(IndexedDB<br/>offline-queue)]
end
subgraph "Service Worker"
SW[Workbox SW<br/>Background Sync<br/>safety net]
end
subgraph "Backend API"
API[REST Endpoint]
end
FORM -->|enqueue| HOOK
HOOK -->|persist| IDB
HOOK -->|process FIFO| API
HOOK -->|update| STATUS
SW -.->|fallback replay<br/>if tab closed| API
API -->|409 Conflict| HOOK
HOOK -->|show conflict UI| FORM
9.2 useOfflineQueue() Hook API¶
// packages/shared-components/src/hooks/useOfflineQueue.ts
interface QueueItem {
id: string; // UUID
method: 'POST' | 'PUT' | 'DELETE';
url: string;
body: unknown;
timestamp: number;
retryCount: number;
status: 'pending' | 'syncing' | 'failed' | 'conflict';
}
interface UseOfflineQueueOptions {
endpoint: string;
conflictResolver?: (local: unknown, server: unknown) => Promise<unknown>;
maxRetries?: number; // Default: 3
}
interface UseOfflineQueueReturn {
enqueue: (item: Omit<QueueItem, 'id' | 'timestamp' | 'retryCount' | 'status'>) => Promise<void>;
syncStatus: 'synced' | 'pending' | 'syncing' | 'failed';
pendingCount: number;
retryAll: () => Promise<void>;
discardFailed: () => Promise<void>;
}
export function useOfflineQueue(options: UseOfflineQueueOptions): UseOfflineQueueReturn;
9.3 Queue Processing Rules¶
- FIFO order — items process in submission order
- Persist to IndexedDB — survives app restart, tab close, device reboot
- Auto-process when online —
navigator.onLine+onlineevent listener - Retry with exponential backoff — 1s, 2s, 4s (max 3 retries)
- 409 Conflict — pause queue, show conflict resolution UI, resume after resolution
- After max retries — surface to user with "Réessayer / Abandonner" options
- Idempotency — each queued item carries a client-generated UUID. Server uses it for deduplication.
9.4 Conflict Resolution UI¶
When the server returns 409 (version mismatch from optimistic locking):
// Default conflict resolver — shows side-by-side comparison
function defaultConflictResolver(local: unknown, server: unknown): Promise<unknown> {
// Opens ConflictDialog component:
// "Garder ma version" → return local
// "Garder la version serveur" → return server
// "Fusionner" → open merge UI (module-specific)
}
9.5 Sync Status Indicator¶
Global header component driven by useOfflineQueue:
| Status | Visual | French Label |
|---|---|---|
synced |
🟢 Green dot | "Synchronisé" |
pending |
🟠 Orange dot | "En attente (3)" |
syncing |
🟠 Pulsing orange | "Synchronisation..." |
failed |
🔴 Red dot | "Échec de synchronisation" |
9.6 Service Worker (Safety Net)¶
Workbox Background Sync registered as a fallback. If the tab closes while items are queued, the SW replays them when connectivity returns. This is a safety net — the primary mechanism is the useOfflineQueue hook.
// frontend/src/sw.ts
import { BackgroundSyncPlugin } from 'workbox-background-sync';
const bgSyncPlugin = new BackgroundSyncPlugin('mutation-queue', {
maxRetentionTime: 24 * 60, // 24 hours
});
10. i18n Architecture¶
ADR-PV-07: Namespace-per-module JSON (frontend-only translations, backend sends error codes)
10.1 Translation File Structure¶
frontend/src/locales/
├── fr/
│ ├── common.json # Shared: buttons, nav, dates, status labels
│ ├── shell.json # App shell: sidebar, header, settings
│ ├── controlPlane.json # CP module strings
│ ├── absence.json # Absence module strings
│ ├── budget.json # Budget module strings
│ ├── errors.json # Error code → user message mapping (ALL modules)
│ └── ... # One per module
└── en/ # Future: English translations (same structure)
├── common.json
└── ...
10.2 Key Naming Convention¶
// fr/absence.json
{
"leaveRequest": {
"title": "Nouvelle demande de congé",
"form": {
"startDate": "Date de début",
"endDate": "Date de fin",
"leaveType": "Type de congé",
"submit": "Soumettre la demande"
},
"status": {
"PENDING": "En attente",
"APPROVED": "Approuvé",
"REJECTED": "Refusé"
}
},
"balance": {
"title": "Solde de congés",
"remaining": "{{count}} jour restant",
"remaining_plural": "{{count}} jours restants"
}
}
Pattern: <feature>.<section>.<key> — dot-separated, camelCase, max 3 levels deep.
10.3 Error Code Mapping¶
// fr/errors.json
{
"ABSENCE_001_OVERLAPPING_LEAVE": "Les dates de votre demande sont en conflit avec un congé existant",
"ABSENCE_002_INSUFFICIENT_BALANCE": "Solde de congés insuffisant ({{available}} jours disponibles)",
"BUDGET_001_LINE_EXHAUSTED": "La ligne budgétaire « {{lineName}} » est épuisée",
"VALIDATION_ERROR": "Veuillez corriger les erreurs du formulaire",
"CONFLICT_VERSION_MISMATCH": "Cette donnée a été modifiée par un autre utilisateur"
}
10.4 Usage in React¶
import { useTranslation } from 'react-i18next';
// Component
const { t } = useTranslation('absence');
<h1>{t('leaveRequest.title')}</h1>
// Error handling
const { t: tError } = useTranslation('errors');
const displayError = (apiError: ApiError) => {
const msg = tError(apiError.code, apiError.params);
// Falls back to apiError.code if no translation exists
return msg;
};
10.5 i18next Configuration¶
// frontend/src/i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
i18n.use(initReactI18next).init({
lng: 'fr',
fallbackLng: 'fr',
ns: ['common', 'shell'], // Loaded eagerly
defaultNS: 'common',
interpolation: { escapeValue: false },
// Module namespaces loaded lazily when route activates
});
10.6 Backend Rule¶
The backend NEVER sends translated text. It sends:
- Error codes (e.g., ABSENCE_001_OVERLAPPING_LEAVE)
- Interpolation params (e.g., { "available": 12 })
The frontend does all translation. This keeps the API language-agnostic — both Papillon and Enterprise frontends (and any future API consumer) get the same response.
11. API Versioning Strategy¶
ADR-PV-09: Per-module independent versioning
11.1 Rules¶
- Each module versions independently:
/api/v2/absence/*can coexist with/api/v1/budget/* - Breaking changes require a new version of THAT module only
- Old version stays alive until all clients have migrated
- Non-breaking additions (new fields, new endpoints) stay on the current version
- Version tracked per module in PROGRESS.md
11.2 What Counts as a Breaking Change¶
| Change | Breaking? |
|---|---|
| Add a new optional field to response | No |
| Add a new endpoint | No |
| Remove a field from response | Yes |
| Rename a field | Yes |
| Change a field's type | Yes |
| Change an endpoint's URL | Yes |
| Add a required field to request body | Yes |
| Change error code format | Yes |
11.3 Implementation¶
// V1 controller stays alive
@RestController
@RequestMapping("/api/v1/absence")
class LeaveRequestControllerV1 {
// Original contract
}
// V2 controller with breaking changes
@RestController
@RequestMapping("/api/v2/absence")
class LeaveRequestControllerV2 {
// New contract — delegates to same service layer
}
Both controllers share the same service layer. Only the DTO mapping differs.
11.4 Current Version Registry¶
| Module | Current Version | Notes |
|---|---|---|
Control Plane (/cp/) |
v1 | |
Budget (/budget/) |
v1 | |
Absence (/absence/) |
v1 | |
| All others | v1 | Not yet started |
12. Security Architecture¶
12.1 Authentication Flow¶
sequenceDiagram
participant Client as Frontend
participant KC as Keycloak
participant API as Spring Boot
participant Filter as TenantResolutionFilter
participant Controller
Client->>KC: POST /token (username, password)
KC->>Client: Access Token (JWT) + Refresh Token
Client->>API: GET /api/v1/absence/... + Bearer JWT
API->>API: Spring Security validates JWT signature
API->>Filter: Authenticated request
Filter->>Filter: Extract tenant_id, country_code, roles from JWT
Filter->>Filter: Lookup db_profile from platform DB
Filter->>Controller: ScopedValue.where(TENANT_CONTEXT, ctx).run(...)
12.2 JWT Claims Structure¶
{
"sub": "user-uuid",
"tenant_id": "tenant-uuid",
"country_code": "CI",
"roles": ["ADMIN", "HR_MANAGER"],
"email": "[email protected]",
"name": "Jean Koné",
"iat": 1741859200,
"exp": 1741862800
}
12.3 Authorization Model (RBAC)¶
| Role | Scope | Typical Permissions |
|---|---|---|
SUPER_ADMIN |
Platform-wide | Manage all tenants, billing, platform config |
TENANT_ADMIN |
Tenant-scoped | Manage users, roles, org structure, module config |
HR_MANAGER |
Tenant-scoped | Manage employees, approve leaves, run payroll |
DEPARTMENT_MANAGER |
OrgUnit-scoped | Approve leaves for their team, view team data |
EMPLOYEE |
Self-scoped | View own data, submit leave requests |
ACCOUNTANT |
Tenant-scoped | View/manage budgets, commitments, expenses |
READONLY |
Tenant-scoped | View-only access to assigned modules |
12.4 Permission Check Pattern¶
// In controller
@PreAuthorize("hasPermission('ABSENCE', 'APPROVE_LEAVE')")
@PostMapping("/leave-requests/{id}/approve")
public ApiResponse<LeaveRequestResponse> approve(@PathVariable UUID id) { ... }
// For OrgUnit-scoped roles (e.g., manager can only approve their team)
@PreAuthorize("hasPermission('ABSENCE', 'APPROVE_LEAVE')")
public ApiResponse<LeaveRequestResponse> approve(@PathVariable UUID id) {
// Service layer checks: is current user the approver for this request's employee?
}
12.5 Tenant Isolation Security Layers¶
Layer 1: JWT tenant_id claim (cannot be forged — Keycloak signs it)
Layer 2: TenantResolutionFilter validates tenant exists + is active
Layer 3: Every SQL query includes WHERE tenant_id = :tenantId
Layer 4: PostgreSQL RLS (defense-in-depth for Profile A)
Layer 5: CI test validates all @Query include tenant_id
13. Multi-Tenant Strategy (All 4 DB Profiles)¶
13.1 Profile Overview¶
| Profile | Code | Tenant Data Location | Who Uses It | Complexity |
|---|---|---|---|---|
| Shared | SHARED |
Single DB, tenant_id column on every row |
SMEs (S1-S3) | Medium |
| Schema-per-tenant | SCHEMA |
Separate schema per tenant in same DB | Mid-market (S4) | Medium |
| DB-per-tenant | DATABASE |
Separate database per tenant | Large (S5) | High |
| BYO-DB | BYO |
Customer-provided database connection | Enterprise (S6) | Highest |
13.2 DataSource Routing¶
graph TD
REQ[Incoming Request]
FILTER[TenantResolutionFilter]
CTX[TenantContext<br/>dbProfile = ?]
REQ --> FILTER
FILTER --> CTX
CTX -->|SHARED| SHARED_DS["Default Tenant DataSource<br/>+ tenant_id filter"]
CTX -->|SCHEMA| SCHEMA_DS["Default Tenant DataSource<br/>+ SET search_path = tenant_xxx"]
CTX -->|DATABASE| DB_DS["Lookup DataSource from pool<br/>by tenantId"]
CTX -->|BYO| BYO_DS["Lookup DataSource from<br/>TenantConnectionConfig"]
Profile A: Shared (MVP default)¶
- All tenants share one
tenantDataSource - Every query includes
WHERE tenant_id = :tenantId - RLS enabled as defense-in-depth
- Simplest to operate, most cost-effective
Profile B: Schema-per-tenant¶
- One PostgreSQL database, one schema per tenant
SET search_path = 'tenant_<uuid_short>'at start of each request- Flyway runs per schema on tenant creation
- Better isolation, slightly more complex provisioning
Profile C: DB-per-tenant¶
- Separate PostgreSQL database per tenant
DataSourceinstances pooled and cached (Caffeine, 1-hour TTL)TenantDataSourceRouterresolves DataSource by tenantId- Full isolation, separate backup/restore per tenant
Profile D: BYO-DB¶
- Customer provides connection string, credentials
- Stored encrypted in platform DB (
tenant_connection_configtable) - DataSource created on-demand, validated before use
- Customer responsible for backup, but ALTARYS responsible for schema (Flyway)
- Design for this first — if it works for BYO-DB, it works for all others
13.3 Flyway Migration per Profile¶
| Profile | Platform DB | Tenant DB |
|---|---|---|
| Shared | db/migration/ → single platform DB |
db/tenant-migration/ → single tenant DB |
| Schema | Same | db/tenant-migration/ → per-schema (Flyway schema target) |
| DB-per-tenant | Same | db/tenant-migration/ → per-database (Flyway URL target) |
| BYO-DB | Same | db/tenant-migration/ → customer-provided URL |
All four profiles use the same migration scripts (db/tenant-migration/). Only the target changes.
14. Flyway Migration Strategy¶
14.1 Migration Locations¶
backend/src/main/resources/
├── db/
│ ├── migration/ # Platform DB migrations
│ │ ├── V001__create_tenant_table.sql
│ │ ├── V002__extend_tenant_table.sql
│ │ └── ...
│ └── tenant-migration/ # Tenant DB migrations (ALL profiles)
│ ├── V001__create_tenant_settings.sql
│ ├── V002__create_event_publication.sql
│ └── ...
14.2 Migration Naming Convention¶
- Forward:
V{NNN}__{description}.sql(double underscore) - Undo:
U{NNN}__{description}.sql - NNN is zero-padded 3-digit sequential number
- Description uses snake_case
14.3 Flyway Configuration¶
// Platform DB Flyway
Flyway.configure()
.dataSource(platformDataSource)
.locations("classpath:db/migration")
.table("flyway_schema_history")
.migrate();
// Tenant DB Flyway
Flyway.configure()
.dataSource(tenantDataSource) // Or per-tenant DataSource for Profile C/D
.locations("classpath:db/tenant-migration")
.table("flyway_schema_history_tenant") // Avoid collision
.baselineOnMigrate(true)
.baselineVersion("0")
.migrate();
14.4 Rules for New Migrations¶
- Never modify an existing migration — always create a new one
- Include tenant_id in every tenant DB table — even if it feels redundant for Profile C/D (keeps queries portable)
- Use
IF NOT EXISTSfor idempotent migrations (especially important for BYO-DB where migrations may be re-run) - Test migrations on empty DB AND on DB with existing data — prevents "works on new tenants, breaks on existing ones"
15. Performance & Caching¶
15.1 Caching Architecture (ADR-04)¶
graph LR
subgraph "L1: In-Process (Caffeine)"
PERM[Permissions<br/>60s TTL]
COUNTRY[Country Config<br/>24h TTL]
end
subgraph "L2: Distributed (Redis)"
TCONF[Tenant Config<br/>5min TTL]
MGATE[Module Gating<br/>5min TTL]
SESSION[User Sessions<br/>JWT lifetime]
end
subgraph "L3: Client (IndexedDB)"
REFDATA[Reference Data<br/>leave types, org units]
QUEUE[Offline Queue<br/>mutations]
end
15.2 Cache Invalidation¶
| Cache | Invalidation Trigger |
|---|---|
| Tenant config | TenantStatusChanged |
| Module gating | ModuleActivated / ModuleDeactivated |
| Permissions | UserRoleChanged |
| Country config | Manual (reference data changes rarely) |
| Client-side ref data | Background refresh on online event |
15.3 Connection Pooling¶
| Profile | Strategy |
|---|---|
| Shared | Single HikariCP pool (max 20 connections) |
| Schema | Single pool + SET search_path per request |
| DB-per-tenant | Pool-per-tenant (max 5 each), Caffeine cache of DataSources (1h TTL, max 50) |
| BYO-DB | Pool-per-tenant (max 3 each), created on-demand, evicted after 30min idle |
15.4 Query Performance Rules¶
- Every table with
tenant_idhas a composite index:(tenant_id, <natural-key>) - Dashboard aggregations use on-the-fly SQL (ADR-15) — no materialized views at MVP scale
- Cursor-based pagination (ADR-12) — no
OFFSETqueries EXPLAIN ANALYZEon every query that touches > 100 rows in tests
16. Shared Test Infrastructure¶
ADR-PV-10: Shared
test-supportmodule
16.1 Test Support Package¶
backend/src/test/java/com/altarys/papillon/testsupport/
├── AbstractTenantIntegrationTest.java # Base class for all integration tests
├── TestTenantFactory.java # Creates test tenants with realistic data
├── TestEmployeeFactory.java # Creates test employees
├── MockJwtDecoder.java # Stubs Keycloak JWT validation
├── TestEventCollector.java # Captures published events for assertions
├── TestTenantContextHelper.java # Sets TenantContext in tests
├── TenantIsolationVerifier.java # Verifies no cross-tenant data leaks
└── QueryTenantIdScanner.java # CI test: scans @Query for tenant_id
16.2 AbstractTenantIntegrationTest¶
// Base class for ALL module integration tests
@SpringBootTest
@Import(TestcontainersConfig.class)
public abstract class AbstractTenantIntegrationTest {
@Autowired TestTenantFactory tenantFactory;
@Autowired TestEventCollector eventCollector;
protected TenantContext testTenant;
@BeforeEach
void setUpTenant() {
testTenant = tenantFactory.createAndProvision("TEST-CORP", "CI", "SHARED");
TenantContextHolder.setFallback(testTenant);
eventCollector.clear();
}
@AfterEach
void tearDown() {
TenantContextHolder.clearFallback();
}
/** Run a block within a specific tenant context */
protected void withTenant(TenantContext ctx, Runnable block) {
TenantContextHolder.setFallback(ctx);
try {
block.run();
} finally {
TenantContextHolder.setFallback(testTenant);
}
}
/** Assert that an event of a given type was published */
protected <T> T assertEventPublished(Class<T> eventType) {
return eventCollector.assertPublished(eventType);
}
}
16.3 TestcontainersConfig (Existing)¶
// Already exists at: backend/src/test/java/com/altarys/papillon/TestcontainersConfig.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
static PostgreSQLContainer<?> platformDb = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("altarys_platform_test");
static PostgreSQLContainer<?> tenantDb = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("altarys_tenant_test");
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
// Maps spring.datasource.* → platformDb
// Maps altarys.datasource.tenant.* → tenantDb
}
}
16.4 MockJwtDecoder¶
// Stubs Keycloak for tests — no real Keycloak container needed
@TestConfiguration
public class MockJwtDecoder {
@Bean
JwtDecoder jwtDecoder() {
return token -> {
// Parse token as a simple JSON, return mock Jwt
// Used with TestJwtConfig.createToken(tenantId, userId, roles)
};
}
}
16.5 QueryTenantIdScanner (CI Test)¶
// Runs in CI to enforce ADR-PV-03 (tenant_id in every query)
@Test
void allQueriesIncludeTenantId() {
// 1. Scan classpath for all interfaces extending CrudRepository
// 2. Find all @Query annotations
// 3. Assert each contains "tenant_id" in the SQL
// 4. Fail with clear message listing violations
}
16.6 Test Patterns Per Module¶
| Test Type | Base Class | What It Tests |
|---|---|---|
| Unit test (domain) | None | State machines, validators, pure logic |
| Service integration | AbstractTenantIntegrationTest |
Business logic + DB + events |
| Controller integration | AbstractTenantIntegrationTest + MockMvc |
API contracts, auth, validation |
| Tenant isolation | AbstractTenantIntegrationTest |
Create 2 tenants, verify no data leaks |
@Query scan |
Standalone JUnit | All queries include tenant_id |
17. Local Dev Environment¶
17.1 docker-compose.yml¶
# docker-compose.yml (project root)
services:
platform-db:
image: postgres:16-alpine
container_name: altarys-platform-db
environment:
POSTGRES_DB: altarys_platform
POSTGRES_USER: altarys
POSTGRES_PASSWORD: altarys
ports:
- "5432:5432"
volumes:
- platform-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U altarys -d altarys_platform"]
interval: 5s
timeout: 3s
retries: 5
tenant-db:
image: postgres:16-alpine
container_name: altarys-tenant-db
environment:
POSTGRES_DB: altarys_tenant
POSTGRES_USER: altarys
POSTGRES_PASSWORD: altarys
ports:
- "5433:5432"
volumes:
- tenant-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U altarys -d altarys_tenant"]
interval: 5s
timeout: 3s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.1
container_name: altarys-keycloak
command: start-dev --import-realm
environment:
KC_DB: dev-mem
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8180:8080"
volumes:
- ./infrastructure/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n' >&3"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: altarys-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
minio:
image: minio/minio:latest
container_name: altarys-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: altarys
MINIO_ROOT_PASSWORD: altarys123
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 3s
retries: 5
volumes:
platform-db-data:
tenant-db-data:
minio-data:
17.2 Environment Variables¶
# Backend (.env or application-local.yml)
PLATFORM_DB_URL=jdbc:postgresql://localhost:5432/altarys_platform
PLATFORM_DB_USERNAME=altarys
PLATFORM_DB_PASSWORD=altarys
TENANT_DB_URL=jdbc:postgresql://localhost:5433/altarys_tenant
TENANT_DB_USERNAME=altarys
TENANT_DB_PASSWORD=altarys
KEYCLOAK_ISSUER_URI=http://localhost:8180/realms/altarys
SERVER_PORT=8080
# Frontend (.env)
VITE_API_BASE_URL=http://localhost:8080
VITE_KEYCLOAK_URL=http://localhost:8180
VITE_KEYCLOAK_REALM=altarys
VITE_KEYCLOAK_CLIENT_ID=papillon-web
17.3 Verification Command¶
# Start all services
docker compose up -d
# Wait for health checks
docker compose ps # All should be "healthy"
# Start backend
./gradlew bootRun
# Start frontend (separate terminal)
cd frontend && pnpm dev
# Verify
curl http://localhost:8080/actuator/health
# → {"status": "UP"}
18. Frontend Architecture¶
18.1 Design System¶
- Technology: Shadcn/ui + Tailwind CSS (ADR-06: Claude frontend-design compatibility, small bundle, CSS variable theming for dual-brand)
- Catalogue: Storybook 10 (
pnpm storybook→ localhost:6006) - Theme injection: CSS variables in
frontend/src/theme/— brand values never hardcoded - Dual-brand support: Papillon (warm, mobile-first) and Enterprise (corporate, desktop-primary) themes via CSS variable override
18.2 Module Structure (Frontend)¶
frontend/src/modules/<module-name>/
├── components/ # Module-specific UI components
├── hooks/ # Custom React hooks (useLeaveRequests, etc.)
├── services/ # API client functions
├── types/ # TypeScript types for this module
├── pages/ # Route pages
└── i18n/
└── fr.json # Module translations
18.3 Shared Components¶
packages/shared-components/
├── src/
│ ├── components/ # Design system components (Button, Input, Card, etc.)
│ ├── hooks/
│ │ └── useOfflineQueue.ts # Shared offline sync hook (ADR-PV-05)
│ ├── contexts/ # Shared React contexts
│ └── theme/ # CSS custom properties (brand-agnostic)
18.4 Data Fetching¶
- TanStack Query (
@tanstack/react-query) for all API calls - Stale-while-revalidate pattern for GET requests
- Mutations go through
useOfflineQueue()for offline support - Query keys include tenantId for cache isolation
// Typical data fetching pattern
const { data, isLoading } = useQuery({
queryKey: ['absence', 'leave-requests', { status: 'PENDING' }],
queryFn: () => leaveRequestService.list({ status: 'PENDING' }),
staleTime: 30_000, // 30s
});
19. Vertical Slice Decomposition¶
These slices implement the shared platform infrastructure. They are Tier 0 for the entire platform — all module-specific work depends on them.
Tier 0 — Platform Foundation (Serial)¶
PV-001: Local Dev Environment (docker-compose)¶
Scope: Create docker-compose.yml with all external dependencies, verify full local startup.
Files:
- docker-compose.yml (new)
- infrastructure/keycloak/realm-export.json (new — minimal Keycloak realm)
- backend/src/main/resources/application-local.yml (new — local profile overrides)
- .env.example (new)
Estimated complexity: S
Dependencies: None
Acceptance criteria:
1. docker compose up -d starts all 5 services (2× PostgreSQL, Keycloak, Redis, MinIO)
2. All containers reach healthy status within 60s
3. ./gradlew bootRun connects to all services without error
4. curl localhost:8080/actuator/health returns UP
PV-002: Shared Event Catalog Module¶
Scope: Create the shared event package with all cross-module event records from PROGRESS.md Event Contracts Registry.
Files:
- backend/src/main/java/com/altarys/papillon/events/ (new package, ~25 record classes)
- One Java record per event from PROGRESS.md Event Contracts Registry
Estimated complexity: M Dependencies: PV-001 Acceptance criteria: 1. All events from PROGRESS.md Event Contracts Registry exist as Java records 2. All events are immutable records with proper field types (UUID, Instant, String, etc.) 3. Spring Modulith architecture test passes (events are in shared kernel) 4. No circular dependencies
PV-003: Module Gating Interceptor¶
Scope: Implement ModuleGatingInterceptor + ModuleGatingService with Redis caching.
Files:
- backend/src/main/java/com/altarys/papillon/platform/config/ModuleGatingInterceptor.java (new)
- backend/src/main/java/com/altarys/papillon/platform/tenant/service/ModuleGatingService.java (new)
- backend/src/main/java/com/altarys/papillon/platform/config/ModuleNotActivatedException.java (new)
- Update GlobalExceptionHandler.java — add handler for ModuleNotActivatedException
- Tests: ModuleGatingInterceptorTest.java, ModuleGatingServiceTest.java
Estimated complexity: M
Dependencies: PV-001
Acceptance criteria:
1. Request to /api/v1/absence/* returns 403 when ABSMGT not active for tenant
2. Request to /api/v1/cp/* always passes (CP not gated)
3. Module activation status cached in Redis (5min TTL)
4. Cache invalidated on ModuleActivated / ModuleDeactivated
PV-004: Audit Event Listener¶
Scope: Implement the Audit module's event listener that captures all domain events as audit entries.
Files:
- backend/src/main/java/com/altarys/papillon/platform/audit/listener/AuditEventListener.java (new)
- backend/src/main/java/com/altarys/papillon/platform/audit/repository/AuditEntryRepository.java (new or update existing)
- backend/src/main/resources/db/tenant-migration/V003__create_audit_entry.sql (new)
- Tests: AuditEventListenerTest.java
Estimated complexity: M
Dependencies: PV-002 (needs event records)
Acceptance criteria:
1. Publishing any domain event creates an audit_entry row
2. Audit entry captures: tenant_id, entity_type, entity_id, action, user_id, timestamp, JSON payload
3. Audit entries partitioned by timestamp (monthly)
4. Audit entries include tenant_id (isolation verified)
PV-005: Shared Test Infrastructure¶
Scope: Create the test-support package with base classes, factories, and CI enforcement tests.
Files:
- backend/src/test/java/com/altarys/papillon/testsupport/AbstractTenantIntegrationTest.java (new)
- backend/src/test/java/com/altarys/papillon/testsupport/TestTenantFactory.java (new)
- backend/src/test/java/com/altarys/papillon/testsupport/TestEventCollector.java (new)
- backend/src/test/java/com/altarys/papillon/testsupport/TestTenantContextHelper.java (new)
- backend/src/test/java/com/altarys/papillon/testsupport/QueryTenantIdScanner.java (new — CI test)
- Refactor existing tests to extend AbstractTenantIntegrationTest
Estimated complexity: M
Dependencies: PV-001
Split candidate: Could split into PV-005a (base classes + factories) and PV-005b (QueryTenantIdScanner CI test)
Acceptance criteria:
1. AbstractTenantIntegrationTest provides TenantContext setup/teardown, event collector, tenant factory
2. Existing tests refactored to use new base class (all 89 still pass)
3. QueryTenantIdScanner scans all @Query annotations, fails if any tenant DB query lacks tenant_id
4. TestEventCollector captures events and provides assertion methods
Tier 1 — Platform Services (Parallel after Tier 0)¶
PV-006: Offline Sync Hook (useOfflineQueue)¶
Scope: Implement the shared offline sync hook in packages/shared-components/.
Files:
- packages/shared-components/src/hooks/useOfflineQueue.ts (new)
- packages/shared-components/src/hooks/useOfflineQueue.test.ts (new)
- packages/shared-components/src/components/SyncStatusIndicator/ (new)
- packages/shared-components/src/components/ConflictDialog/ (new)
- packages/shared-components/src/lib/idb.ts (new — IndexedDB wrapper)
Estimated complexity: L
Dependencies: PV-001
Split candidate: Could split into PV-006a (queue + IndexedDB) and PV-006b (conflict UI + sync indicator)
Acceptance criteria:
1. enqueue() persists mutation to IndexedDB
2. Queue processes FIFO when navigator.onLine is true
3. Failed items retry with exponential backoff (1s, 2s, 4s)
4. 409 responses trigger conflict resolver callback
5. syncStatus reflects current queue state
6. SyncStatusIndicator renders correct dot color + French label
PV-007: i18n Foundation¶
Scope: Set up i18n infrastructure with namespace lazy-loading, error code mapping, and common translations.
Files:
- frontend/src/i18n/config.ts (update — add lazy loading, namespace support)
- frontend/src/locales/fr/common.json (new — shared buttons, nav, dates, statuses)
- frontend/src/locales/fr/errors.json (new — error code → French message mapping)
- frontend/src/locales/fr/shell.json (update existing)
- frontend/src/lib/errorDisplay.ts (new — maps ApiError code → translated string)
Estimated complexity: S
Dependencies: PV-001
Acceptance criteria:
1. useTranslation('common') returns shared French strings
2. useTranslation('errors') maps error codes to French messages
3. Module namespaces load lazily (only when route activates)
4. Missing translation falls back to error code (never crashes)
PV-008: RLS Defense-in-Depth (Profile A)¶
Scope: Add PostgreSQL Row-Level Security policies to tenant DB tables for Profile A (shared DB).
Files:
- backend/src/main/resources/db/tenant-migration/V004__enable_rls.sql (new)
- Update TenantResolutionFilter — add SET LOCAL app.current_tenant_id for Profile A
- Tests: RlsIsolationTest.java
Estimated complexity: M
Dependencies: PV-001, PV-005
Acceptance criteria:
1. RLS policies exist on all tenant DB tables
2. Direct SQL query without SET LOCAL returns zero rows (even if data exists)
3. Query with correct SET LOCAL returns only that tenant's data
4. RLS active only for Profile A (SHARED) — no-op for other profiles
20. Architectural Decision Records¶
Summary of all decisions made in this document:
| # | Decision | Choice | Rationale |
|---|---|---|---|
| ADR-PV-01 | TenantContext propagation | ScopedValue (Java 25) + ThreadLocal fallback | Immutable, virtual-thread safe, no leaking. Fallback for @Scheduled/tests/Flyway. |
| ADR-PV-02 | Cross-module event contracts | Shared event module (com.altarys.papillon.events) |
Shared kernel pattern. Thin event catalog that all modules depend on. Enables future microservice extraction. |
| ADR-PV-03 | Tenant ID enforcement | Convention + RLS + CI test (3 layers) | Explicit WHERE tenant_id in every query. RLS as defense-in-depth. CI test as automated verification. No AOP magic. |
| ADR-PV-04 | Error handling | Global @ControllerAdvice + module error codes |
One handler guarantees uniform { data, meta, errors } envelope. Module error codes are namespaced (ABSENCE_001, BUDGET_001). |
| ADR-PV-05 | Offline sync framework | Shared useOfflineQueue() hook |
One implementation, consistent UX, every module imports the same hook. IndexedDB + FIFO + retry + conflict UI. |
| ADR-PV-06 | Audit trail integration | Application Events (audit as consumer) | Zero coupling. Modules already publish events (ADR-14). Audit module listens. Guaranteed delivery via EVENT_PUBLICATION. |
| ADR-PV-07 | i18n architecture | Namespace-per-module JSON, frontend-only | Backend sends error codes + params. Frontend translates. API stays language-agnostic. Lazy-loaded namespaces for bundle size. |
| ADR-PV-08 | Module gating | Spring ModuleGatingInterceptor |
Centralized URL-prefix → module-code mapping. Impossible to forget. Redis-cached. One config to maintain. |
| ADR-PV-09 | API versioning | Per-module independent versioning | Modules evolve independently. No big-bang v2. Industry standard (Stripe, Twilio). |
| ADR-PV-10 | Shared test infrastructure | test-support module |
DRY base classes, factories, event collector, CI enforcement. All module tests extend same foundation. |
21. Technical Risks & Mitigations¶
| # | Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|---|
| R1 | ScopedValue doesn't propagate across @Async |
TenantContext lost in async operations | Medium | ThreadLocal fallback + async task decorator that copies context |
| R2 | BYO-DB DataSource pool exhaustion | Connection starvation for BYO tenants | Low (MVP) | Max 3 connections per BYO tenant, 30min idle eviction, circuit breaker |
| R3 | Cross-tenant data leak via missing tenant_id |
Critical security breach | Low (CI test catches) | 3-layer enforcement (convention + RLS + CI scan). RLS is the last line of defense. |
| R4 | Event listener failure causes audit gaps | Missing audit entries | Low (guaranteed delivery) | Spring Modulith EVENT_PUBLICATION table. Failed events retry automatically. Monitoring on uncompleted publications. |
| R5 | IndexedDB quota exceeded on mobile | Offline queue full, mutations lost | Low | Queue max size (100 items). Old items surface "sync now" warning. Background Sync as safety net. |
| R6 | Module gating cache stale after activation | User gets 403 for 5 minutes after activating a module | Medium | Cache invalidation via ModuleActivated. Fallback: user refresh. Short TTL (5min). |
| R7 | Keycloak realm drift between dev environments | JWT claim format inconsistent | Medium | realm-export.json in repo, auto-imported by docker-compose. Test JwtTenantExtractor with known claims. |
| R8 | Flyway migration order conflict across modules | Two modules create same V003__ |
Low | Module-prefixed migration numbers: V003__absmgt_create_leave_request.sql. Documented in standards. |
| R9 | i18n error code mapping incomplete | User sees raw error code instead of French message | Medium | Fallback: display code as-is. CI test: ensure every error code constant has a matching entry in errors.json. |
| R10 | Dual DataSource test isolation | Test data leaks between platform and tenant DB assertions | Low | TestcontainersConfig creates separate containers. @Transactional test rollback per datasource. |